Jitendra12421 commited on
Commit
f35e063
·
verified ·
1 Parent(s): bc06848

Upload runtime.py

Browse files
Files changed (1) hide show
  1. runtime.py +425 -0
runtime.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, time, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from zoneinfo import ZoneInfo
10
+
11
+ import joblib
12
+ import numpy as np
13
+ import pandas as pd
14
+ import yfinance as yf
15
+
16
+
17
+ IST = ZoneInfo("Asia/Kolkata")
18
+ YAHOO_NIFTY_SYMBOL = "^NSEI"
19
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
20
+ DATA_DIR = BACKEND_ROOT / "data"
21
+ MODEL_DIR = BACKEND_ROOT / "models"
22
+ OPENING_DATASET_PATH = DATA_DIR / "opening_direction_training_dataset.parquet"
23
+ NIFTY_1M_PATH = DATA_DIR / "nifty50_1m.parquet"
24
+ NIFTY_1D_PATH = DATA_DIR / "nifty50_1d.parquet"
25
+ MODEL_PATH = MODEL_DIR / "nifty_opening_direction_model.joblib"
26
+ LATEST_PATH = MODEL_DIR / "latest_prediction.csv"
27
+ TEST_PREDICTIONS_PATH = DATA_DIR / "test_predictions.parquet"
28
+
29
+ DECISION_OVERLAYS = [
30
+ {
31
+ "name": "fifth_minute_momentum_flip",
32
+ "feature": "m5_ret_1m",
33
+ "op": ">=",
34
+ "value": 0.0005085411885759201,
35
+ },
36
+ {
37
+ "name": "vix_stretch_flip",
38
+ "feature": "india_vix_close_vs_sma_20",
39
+ "op": ">=",
40
+ "value": 0.24641908937959742,
41
+ },
42
+ ]
43
+
44
+
45
+ class ProbabilityBlend:
46
+ def __init__(self, models: list[Any], weights: np.ndarray):
47
+ self.models = models
48
+ self.weights = np.asarray(weights, dtype="float64")
49
+ self.weights = self.weights / self.weights.sum()
50
+
51
+ def predict_proba(self, x: pd.DataFrame) -> np.ndarray:
52
+ probs = np.column_stack([predict_proba_up(model, x) for model in self.models])
53
+ prob_up = probs @ self.weights
54
+ return np.column_stack([1.0 - prob_up, prob_up])
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Prediction:
59
+ input_date: str
60
+ first5_start: str
61
+ first5_end: str
62
+ prediction: str
63
+ prob_up: float
64
+ confidence: float
65
+ threshold: float
66
+ model_name: str
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return {
70
+ "input_date": self.input_date,
71
+ "first5_start": self.first5_start,
72
+ "first5_end": self.first5_end,
73
+ "prediction": self.prediction,
74
+ "prob_up": self.prob_up,
75
+ "confidence": self.confidence,
76
+ "threshold": self.threshold,
77
+ "model_name": self.model_name,
78
+ }
79
+
80
+
81
+ def predict_proba_up(model: Any, x: pd.DataFrame) -> np.ndarray:
82
+ return np.asarray(model.predict_proba(x)[:, 1], dtype="float64")
83
+
84
+
85
+ def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
86
+ n = pd.Series(numer, copy=False)
87
+ d = pd.Series(denom, copy=False)
88
+ out = pd.Series(np.nan, index=n.index, dtype="float64")
89
+ mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
90
+ out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
91
+ return out
92
+
93
+
94
+ def load_model() -> dict[str, Any]:
95
+ # Existing artifact was trained as a script, so its custom blend class
96
+ # resolves through __main__ when unpickled.
97
+ sys.modules["__main__"].ProbabilityBlend = ProbabilityBlend
98
+ sys.modules["__main__"].predict_proba_up = predict_proba_up
99
+ payload = joblib.load(MODEL_PATH)
100
+ payload.setdefault("decision_overlays", DECISION_OVERLAYS)
101
+ payload.setdefault("model_name", "nifty_opening_direction_model")
102
+ return payload
103
+
104
+
105
+ def overlay_mask(frame: pd.DataFrame, overlay: dict[str, object]) -> np.ndarray:
106
+ feature = str(overlay["feature"])
107
+ if feature not in frame.columns:
108
+ return np.zeros(len(frame), dtype=bool)
109
+ series = pd.to_numeric(frame[feature], errors="coerce")
110
+ value = float(overlay["value"])
111
+ if overlay["op"] == ">=":
112
+ return (series >= value).fillna(False).to_numpy(dtype=bool)
113
+ if overlay["op"] == "<=":
114
+ return (series <= value).fillna(False).to_numpy(dtype=bool)
115
+ raise ValueError(f"Unsupported overlay op: {overlay['op']}")
116
+
117
+
118
+ def apply_decision_overlays(pred: np.ndarray, frame: pd.DataFrame, overlays: list[dict[str, object]]) -> np.ndarray:
119
+ adjusted = np.asarray(pred, dtype="int64").copy()
120
+ for overlay in overlays:
121
+ mask = overlay_mask(frame, overlay)
122
+ adjusted[mask] = 1 - adjusted[mask]
123
+ return adjusted
124
+
125
+
126
+ def directional_confidence(prob_up: np.ndarray, pred: np.ndarray, threshold: float) -> np.ndarray:
127
+ prob_up = np.asarray(prob_up, dtype="float64")
128
+ pred = np.asarray(pred, dtype="int64")
129
+ base_side_prob = np.where(pred == 1, prob_up, 1.0 - prob_up)
130
+ threshold_distance = np.abs(prob_up - float(threshold))
131
+ return np.clip(0.50 + threshold_distance, base_side_prob, 0.99)
132
+
133
+
134
+ def read_training_dataset() -> pd.DataFrame:
135
+ df = pd.read_parquet(OPENING_DATASET_PATH)
136
+ for col in ("date", "first5_start", "first5_end"):
137
+ if col in df.columns:
138
+ df[col] = pd.to_datetime(df[col], errors="coerce")
139
+ return df.sort_values("date").reset_index(drop=True)
140
+
141
+
142
+ def normalize_yahoo_frame(df: pd.DataFrame) -> pd.DataFrame:
143
+ if df.empty:
144
+ return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
145
+ if isinstance(df.columns, pd.MultiIndex):
146
+ df.columns = [str(c[0]).lower() for c in df.columns]
147
+ else:
148
+ df.columns = [str(c).lower().replace(" ", "_") for c in df.columns]
149
+ df = df.reset_index()
150
+ date_col = next((c for c in df.columns if c.lower() in {"datetime", "date"}), df.columns[0])
151
+ df["date"] = pd.to_datetime(df[date_col], errors="coerce")
152
+ if df["date"].dt.tz is None:
153
+ df["date"] = df["date"].dt.tz_localize("UTC").dt.tz_convert(IST)
154
+ else:
155
+ df["date"] = df["date"].dt.tz_convert(IST)
156
+ rename = {
157
+ "open": "open",
158
+ "high": "high",
159
+ "low": "low",
160
+ "close": "close",
161
+ "adj_close": "close",
162
+ "volume": "volume",
163
+ }
164
+ out = pd.DataFrame({"date": df["date"].dt.tz_localize(None)})
165
+ for src, dst in rename.items():
166
+ if src in df.columns and dst not in out.columns:
167
+ out[dst] = pd.to_numeric(df[src], errors="coerce")
168
+ return out.dropna(subset=["date", "open", "high", "low", "close"]).sort_values("date")
169
+
170
+
171
+ def fetch_yahoo_minutes(period: str = "5d") -> pd.DataFrame:
172
+ raw = yf.download(YAHOO_NIFTY_SYMBOL, period=period, interval="1m", progress=False, prepost=False, auto_adjust=False)
173
+ return normalize_yahoo_frame(raw)
174
+
175
+
176
+ def fetch_yahoo_daily(period: str = "1mo") -> pd.DataFrame:
177
+ raw = yf.download(YAHOO_NIFTY_SYMBOL, period=period, interval="1d", progress=False, prepost=False, auto_adjust=False)
178
+ out = normalize_yahoo_frame(raw)
179
+ out["date"] = pd.to_datetime(out["date"], errors="coerce").dt.normalize()
180
+ return out.drop_duplicates("date", keep="last")
181
+
182
+
183
+ def append_parquet_rows(path: Path, new_rows: pd.DataFrame, subset: list[str]) -> pd.DataFrame:
184
+ if path.exists():
185
+ existing = pd.read_parquet(path)
186
+ combined = pd.concat([existing, new_rows], ignore_index=True)
187
+ else:
188
+ combined = new_rows.copy()
189
+ combined = combined.drop_duplicates(subset=subset, keep="last").sort_values(subset).reset_index(drop=True)
190
+ combined.to_parquet(path, index=False, compression="zstd")
191
+ return combined
192
+
193
+
194
+ def first5_features_from_minutes(minutes: pd.DataFrame, session_date: date | None = None) -> pd.DataFrame:
195
+ if minutes.empty:
196
+ raise RuntimeError("Yahoo returned no minute bars.")
197
+ bars = minutes.copy()
198
+ bars["dt"] = pd.to_datetime(bars["date"], errors="coerce")
199
+ bars["session_date"] = bars["dt"].dt.normalize()
200
+ if session_date is None:
201
+ session_ts = bars["session_date"].max()
202
+ else:
203
+ session_ts = pd.Timestamp(session_date).normalize()
204
+ day = bars[bars["session_date"] == session_ts].sort_values("dt").copy()
205
+ start_dt = pd.Timestamp.combine(session_ts.date(), time(9, 15))
206
+ end_dt = pd.Timestamp.combine(session_ts.date(), time(9, 19))
207
+ first5 = day[(day["dt"] >= start_dt) & (day["dt"] <= end_dt)].head(5).copy()
208
+ if len(first5) < 5:
209
+ raise RuntimeError(f"Need 5 opening bars for {session_ts.date()}, got {len(first5)}.")
210
+ first5["minute_index"] = np.arange(len(first5))
211
+ first5["ret_1m"] = first5["close"].pct_change(fill_method=None)
212
+ first5["range_pct_1m"] = safe_div(first5["high"] - first5["low"], first5["open"])
213
+ first5["body_pct_1m"] = safe_div(first5["close"] - first5["open"], first5["open"])
214
+ row = {
215
+ "date": session_ts,
216
+ "first5_start": first5["dt"].iloc[0],
217
+ "first5_end": first5["dt"].iloc[-1],
218
+ "first5_open": first5["open"].iloc[0],
219
+ "first5_high": first5["high"].max(),
220
+ "first5_low": first5["low"].min(),
221
+ "first5_close": first5["close"].iloc[-1],
222
+ "first5_volume": first5["volume"].sum() if "volume" in first5 else 0.0,
223
+ "first5_bars": len(first5),
224
+ "first5_last_1m_ret": first5["ret_1m"].iloc[-1],
225
+ "first5_ret_std": first5["ret_1m"].std(),
226
+ }
227
+ row["first5_return"] = (row["first5_close"] - row["first5_open"]) / row["first5_open"]
228
+ row["first5_range_pct"] = (row["first5_high"] - row["first5_low"]) / row["first5_open"]
229
+ first5_range = row["first5_high"] - row["first5_low"]
230
+ row["first5_body_to_range"] = (row["first5_close"] - row["first5_open"]) / first5_range if first5_range else np.nan
231
+ row["first5_close_location"] = (row["first5_close"] - row["first5_low"]) / first5_range if first5_range else np.nan
232
+ for idx, (_, candle) in enumerate(first5.iterrows(), start=1):
233
+ for field in ("open", "high", "low", "close", "ret_1m", "range_pct_1m", "body_pct_1m"):
234
+ row[f"m{idx}_{field}"] = candle[field]
235
+ row[f"m{idx}_close_vs_first5_open"] = (candle["close"] - row["first5_open"]) / row["first5_open"]
236
+ row[f"m{idx}_range_share"] = (candle["high"] - candle["low"]) / first5_range if first5_range else np.nan
237
+ row["first5_return_accel"] = row["m5_ret_1m"] - row["m2_ret_1m"]
238
+ row["first5_last2_return"] = (row["m5_close"] - row["m4_open"]) / row["m4_open"]
239
+ row["first5_first2_return"] = (row["m2_close"] - row["m1_open"]) / row["m1_open"]
240
+ row["first5_reversal"] = np.sign(row["first5_first2_return"]) * -np.sign(row["first5_last2_return"])
241
+ row["dow"] = session_ts.dayofweek
242
+ row["dom"] = session_ts.day
243
+ row["month"] = session_ts.month
244
+ return pd.DataFrame([row])
245
+
246
+
247
+ def build_model_row(first5_row: pd.DataFrame) -> pd.DataFrame:
248
+ dataset = read_training_dataset()
249
+ latest_context = dataset.iloc[[-1]].copy()
250
+ output = latest_context.copy()
251
+ for col in first5_row.columns:
252
+ output[col] = first5_row[col].iloc[0]
253
+ if {"first5_open", "nifty_close"}.issubset(output.columns):
254
+ output["first5_gap_from_prev_close"] = (output["first5_open"] - output["nifty_close"]) / output["nifty_close"]
255
+ output["first5_close_vs_prev_close"] = (output["first5_close"] - output["nifty_close"]) / output["nifty_close"]
256
+ if {"first5_range_pct", "nifty_range_pct"}.issubset(output.columns):
257
+ output["first5_range_vs_prev_range"] = output["first5_range_pct"] / output["nifty_range_pct"]
258
+ if {"first5_return", "nifty_ret_1"}.issubset(output.columns):
259
+ output["first5_return_x_prev_ret"] = output["first5_return"] * output["nifty_ret_1"]
260
+ output["gap_x_prev_ret"] = output["first5_gap_from_prev_close"] * output["nifty_ret_1"]
261
+ if {"first5_return", "banknifty_ret_1"}.issubset(output.columns):
262
+ output["first5_return_x_bank_ret_1"] = output["first5_return"] * output["banknifty_ret_1"]
263
+ if {"first5_range_pct", "india_vix_ret_1"}.issubset(output.columns):
264
+ output["first5_range_x_vix_ret_1"] = output["first5_range_pct"] * output["india_vix_ret_1"]
265
+ output["target"] = np.nan
266
+ output["day_return"] = np.nan
267
+ return output
268
+
269
+
270
+ def predict_row(row: pd.DataFrame) -> Prediction:
271
+ payload = load_model()
272
+ model = payload["model"]
273
+ features = payload["features"]
274
+ threshold = float(payload["threshold"])
275
+ missing = [c for c in features if c not in row.columns]
276
+ if missing:
277
+ raise RuntimeError(f"Feature row is missing {len(missing)} features; first missing: {missing[:5]}")
278
+ prob_up = predict_proba_up(model, row[features])
279
+ raw_pred = (prob_up >= threshold).astype("int64")
280
+ pred = apply_decision_overlays(raw_pred, row, payload.get("decision_overlays", DECISION_OVERLAYS))
281
+ confidence = directional_confidence(prob_up, pred, threshold)
282
+ prediction = Prediction(
283
+ input_date=pd.to_datetime(row["date"].iloc[0]).date().isoformat(),
284
+ first5_start=str(pd.to_datetime(row["first5_start"].iloc[0])),
285
+ first5_end=str(pd.to_datetime(row["first5_end"].iloc[0])),
286
+ prediction="UP" if int(pred[0]) == 1 else "DOWN",
287
+ prob_up=float(prob_up[0]),
288
+ confidence=float(confidence[0]),
289
+ threshold=threshold,
290
+ model_name=str(payload.get("model_name", "nifty_opening_direction_model")),
291
+ )
292
+ pd.DataFrame([prediction.to_dict()]).to_csv(LATEST_PATH, index=False)
293
+ return prediction
294
+
295
+
296
+ def latest_saved_prediction() -> dict[str, Any]:
297
+ if LATEST_PATH.exists():
298
+ return pd.read_csv(LATEST_PATH).iloc[-1].to_dict()
299
+ summary_path = MODEL_DIR / "summary.json"
300
+ if summary_path.exists():
301
+ return json.loads(summary_path.read_text(encoding="utf-8"))
302
+ raise FileNotFoundError("No latest prediction is available yet.")
303
+
304
+
305
+ def _json_ready_frame(df: pd.DataFrame, limit: int | None = None) -> list[dict[str, Any]]:
306
+ out = df.copy()
307
+ if limit is not None:
308
+ out = out.tail(limit)
309
+ for col in out.columns:
310
+ if pd.api.types.is_datetime64_any_dtype(out[col]):
311
+ out[col] = out[col].dt.strftime("%Y-%m-%d %H:%M:%S")
312
+ out = out.replace({np.nan: None})
313
+ return out.to_dict(orient="records")
314
+
315
+
316
+ def load_model_summary() -> dict[str, Any]:
317
+ summary_path = MODEL_DIR / "summary.json"
318
+ if not summary_path.exists():
319
+ return {}
320
+ return json.loads(summary_path.read_text(encoding="utf-8"))
321
+
322
+
323
+ def load_candidate_results() -> list[dict[str, Any]]:
324
+ path = MODEL_DIR / "candidate_results.csv"
325
+ if not path.exists():
326
+ return []
327
+ return _json_ready_frame(pd.read_csv(path).head(12))
328
+
329
+
330
+ def load_test_predictions() -> pd.DataFrame:
331
+ if not TEST_PREDICTIONS_PATH.exists():
332
+ return pd.DataFrame()
333
+ df = pd.read_parquet(TEST_PREDICTIONS_PATH)
334
+ df["date"] = pd.to_datetime(df["date"], errors="coerce")
335
+ return df.sort_values("date").reset_index(drop=True)
336
+
337
+
338
+ def dashboard_payload() -> dict[str, Any]:
339
+ summary = load_model_summary()
340
+ latest = latest_saved_prediction()
341
+ test = load_test_predictions()
342
+ daily = pd.read_parquet(NIFTY_1D_PATH)
343
+ daily["date"] = pd.to_datetime(daily["date"], errors="coerce")
344
+ daily = daily.sort_values("date").tail(180)
345
+ dataset = read_training_dataset()
346
+ opening = dataset[["date", "first5_return", "first5_range_pct", "first5_close_location"]].tail(120).copy()
347
+
348
+ if not test.empty:
349
+ recent_predictions = test.tail(40).copy()
350
+ recent_accuracy = float(recent_predictions["correct"].mean())
351
+ direction_mix = test.groupby("prediction")["correct"].agg(["count", "mean"]).reset_index()
352
+ monthly = (
353
+ test.assign(month=test["date"].dt.strftime("%Y-%m"))
354
+ .groupby("month", as_index=False)["correct"]
355
+ .mean()
356
+ .rename(columns={"correct": "accuracy"})
357
+ )
358
+ else:
359
+ recent_predictions = pd.DataFrame()
360
+ recent_accuracy = None
361
+ direction_mix = pd.DataFrame()
362
+ monthly = pd.DataFrame()
363
+
364
+ metrics = {
365
+ "validation_accuracy": summary.get("validation_accuracy"),
366
+ "test_accuracy": summary.get("test_accuracy"),
367
+ "baseline_test_accuracy": summary.get("baseline_test_accuracy"),
368
+ "validation_auc": summary.get("validation_auc"),
369
+ "test_auc": summary.get("test_auc"),
370
+ "test_brier": summary.get("test_brier"),
371
+ "feature_count": summary.get("feature_count"),
372
+ "recent_accuracy": recent_accuracy,
373
+ "recent_accuracy_days": int(len(recent_predictions)) if not recent_predictions.empty else 0,
374
+ "total_test_days": int(len(test)) if not test.empty else 0,
375
+ }
376
+ return {
377
+ "latest": latest,
378
+ "metrics": metrics,
379
+ "summary": summary,
380
+ "candidates": load_candidate_results(),
381
+ "charts": {
382
+ "daily_close": _json_ready_frame(daily[["date", "open", "high", "low", "close"]]),
383
+ "opening_features": _json_ready_frame(opening),
384
+ "monthly_accuracy": _json_ready_frame(monthly),
385
+ "direction_mix": _json_ready_frame(direction_mix),
386
+ "recent_predictions": _json_ready_frame(recent_predictions),
387
+ },
388
+ "data_status": {
389
+ "nifty_1m_rows": int(len(pd.read_parquet(NIFTY_1M_PATH, columns=["date"]))),
390
+ "nifty_1d_rows": int(len(pd.read_parquet(NIFTY_1D_PATH, columns=["date"]))),
391
+ "training_rows": int(len(dataset)),
392
+ "test_prediction_rows": int(len(test)),
393
+ "latest_daily_date": pd.to_datetime(daily["date"]).max().date().isoformat(),
394
+ },
395
+ }
396
+
397
+
398
+ def refresh_first5_prediction(session_date: date | None = None) -> Prediction:
399
+ minutes = fetch_yahoo_minutes(period="5d")
400
+ append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
401
+ first5 = first5_features_from_minutes(minutes, session_date=session_date)
402
+ row = build_model_row(first5)
403
+ dataset = read_training_dataset()
404
+ merged = pd.concat([dataset, row], ignore_index=True)
405
+ merged = merged.drop_duplicates(subset=["date"], keep="last").sort_values("date").reset_index(drop=True)
406
+ merged.to_parquet(OPENING_DATASET_PATH, index=False, compression="zstd")
407
+ return predict_row(row)
408
+
409
+
410
+ def refresh_daily_data() -> dict[str, Any]:
411
+ daily = fetch_yahoo_daily(period="1mo")
412
+ combined = append_parquet_rows(NIFTY_1D_PATH, daily, ["date"])
413
+ return {
414
+ "rows": int(len(combined)),
415
+ "latest_date": pd.to_datetime(combined["date"]).max().date().isoformat(),
416
+ "path": str(NIFTY_1D_PATH),
417
+ }
418
+
419
+
420
+ def seconds_until_next_ist_run(run_time: time = time(9, 20)) -> float:
421
+ now = datetime.now(IST)
422
+ target = datetime.combine(now.date(), run_time, tzinfo=IST)
423
+ if now >= target:
424
+ target += timedelta(days=1)
425
+ return max(1.0, (target - now).total_seconds())