Khanna, Videh Rakesh Rakesh Claude Sonnet 4.6 commited on
Commit
d62ac62
·
1 Parent(s): 8dab0cd

fix: server-side validation scheduler at 4:30pm IST + history persistence

Browse files

- Add background thread (_start_validation_scheduler) that auto-runs
pending validations at 4:30pm IST every NSE trading day — no longer
depends on the browser opening the validation tab
- self_learning.analyze_and_update now saves individual validated records
to learnings.json ("records" key, capped at 500) so history tab
survives DB pruning
- validation_summary always merges DB + JSON records (dedup by id) instead
of using JSON only as a fallback when DB is empty
- revalidate-all now covers JSON-only records (pruned from DB) and writes
updated hit/miss back to learnings.json
- Prune only fires when analyze_and_update returns valid data (not
insufficient_data), ensuring data is never deleted before JSON is written
- Execute response now includes hits/misses counts; toast shows
"X HIT · Y MISS" in green/red instead of just "validated X predictions"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +257 -56
  2. self_learning.py +34 -0
  3. static/app.js +4 -2
  4. static/style.css +3 -1
app.py CHANGED
@@ -2399,17 +2399,27 @@ def validation_execute():
2399
  logging.warning(f"Error validating snapshot {snap.get('id')}: {e}")
2400
 
2401
  # Auto-update self-learning after validations complete, then prune validated rows.
 
 
2402
  if validated_count > 0:
2403
  try:
2404
  from self_learning import analyze_and_update
2405
- analyze_and_update(days=30)
2406
- pruned = db.prune_validated_snapshots()
2407
- if pruned:
2408
- app.logger.info("Pruned %d validated snapshots after self-learning update", pruned)
 
2409
  except Exception as _le:
2410
  app.logger.warning("Self-learning update failed: %s", _le)
2411
 
2412
- return _json_no_store({"validated": validated_count, "results": results})
 
 
 
 
 
 
 
2413
 
2414
 
2415
  @app.route("/api/ai-learn", methods=["POST"])
@@ -2419,7 +2429,9 @@ def ai_learn():
2419
  from self_learning import analyze_and_update
2420
  days = int((request.get_json(silent=True) or {}).get("days", 30))
2421
  result = analyze_and_update(days=days)
2422
- pruned = db.prune_validated_snapshots()
 
 
2423
  result["pruned"] = pruned
2424
  return jsonify(result)
2425
  except Exception as e:
@@ -2442,76 +2454,149 @@ def validation_summary():
2442
  days = request.args.get("days", "30", type=int)
2443
  summary = db.get_validation_summary(days=days)
2444
  history = db.get_validation_history(limit=200)
2445
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2446
  return _json_no_store({"summary": summary, "history": history})
2447
 
2448
 
2449
  @app.route("/api/validation/revalidate-all", methods=["POST"])
2450
  def validation_revalidate_all():
2451
- """Re-run validation for all previously validated snapshots using correct historical prices."""
2452
- history = db.get_validation_history(limit=9999)
 
 
 
 
 
 
 
 
 
 
 
 
 
2453
  revalidated = 0
2454
  results = []
2455
- for snap in history:
2456
- try:
2457
- snapshot_id = snap.get("id")
2458
- ticker = snap.get("ticker")
2459
- entry_price = snap.get("current_price", 0)
2460
- direction = snap.get("direction", "NEUTRAL")
2461
- target_date_str = snap.get("validation_target_date")
2462
 
2463
- if (direction or "").upper() in ("NO TRADE", "N/A"):
2464
- db.mark_prediction_skipped(snapshot_id)
2465
- continue
2466
- if not entry_price or entry_price <= 0:
2467
- continue
 
 
2468
 
2469
- created_at_str = (snap.get("created_at") or "")[:10]
2470
- try:
2471
- window_start_str = (
2472
- datetime.strptime(created_at_str, "%Y-%m-%d") + timedelta(days=1)
2473
- ).strftime("%Y-%m-%d")
2474
- except ValueError:
2475
- window_start_str = target_date_str
2476
-
2477
- window_high, window_low, actual_price = _fetch_price_window(
2478
- ticker, window_start_str, target_date_str
2479
- )
2480
- if not actual_price:
2481
- continue
2482
 
2483
- actual_return = round((actual_price - entry_price) / entry_price * 100, 2)
2484
- target_price_lo = snap.get("target_price_lo") or 0
2485
- target_price_hi = snap.get("target_price_hi") or 0
 
 
 
 
 
 
 
 
 
 
2486
 
2487
- hit = _intraday_target_hit(
2488
- direction, window_high, window_low, actual_price,
2489
- target_price_lo, target_price_hi, entry_price,
2490
- )
2491
- if hit is None:
2492
- continue
2493
- target_hit = hit
2494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2495
  db.validate_prediction(
2496
  snapshot_id=snapshot_id,
2497
  actual_price=actual_price,
2498
  actual_return=actual_return,
2499
- target_hit=target_hit,
2500
  window_high=window_high,
2501
  window_low=window_low,
2502
  )
2503
- results.append({
2504
- "ticker": ticker,
2505
- "timeframe": snap.get("timeframe"),
2506
- "direction": direction,
2507
- "window_high": window_high,
2508
- "window_low": window_low,
2509
- "actual_return": actual_return,
2510
- "target_hit": target_hit,
2511
- })
2512
- revalidated += 1
 
 
 
 
 
 
2513
  except Exception as e:
2514
- logging.warning(f"revalidate-all error for snapshot {snap.get('id')}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2515
  return _json_no_store({"revalidated": revalidated, "results": results})
2516
 
2517
 
@@ -2644,10 +2729,126 @@ def _start_trade_monitor():
2644
  threading.Thread(target=_run, daemon=True, name="trade-monitor").start()
2645
 
2646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2647
  # Start background services at import time so both `python app.py` and
2648
  # WSGI servers (gunicorn) warm the top5 cache and run the trade monitor.
2649
  _prewarm_top5()
2650
  _start_trade_monitor()
 
2651
 
2652
  if __name__ == "__main__":
2653
  port = int(os.environ.get("PORT", 7860))
 
2399
  logging.warning(f"Error validating snapshot {snap.get('id')}: {e}")
2400
 
2401
  # Auto-update self-learning after validations complete, then prune validated rows.
2402
+ # Only prune if learnings.json was successfully written with valid data so the
2403
+ # history tab can fall back to the records stored there.
2404
  if validated_count > 0:
2405
  try:
2406
  from self_learning import analyze_and_update
2407
+ learn_result = analyze_and_update(days=30)
2408
+ if learn_result.get("status") != "insufficient_data":
2409
+ pruned = db.prune_validated_snapshots()
2410
+ if pruned:
2411
+ app.logger.info("Pruned %d validated snapshots after self-learning update", pruned)
2412
  except Exception as _le:
2413
  app.logger.warning("Self-learning update failed: %s", _le)
2414
 
2415
+ hits = sum(1 for r in results if r.get("target_hit"))
2416
+ misses = sum(1 for r in results if not r.get("target_hit"))
2417
+ return _json_no_store({
2418
+ "validated": validated_count,
2419
+ "hits": hits,
2420
+ "misses": misses,
2421
+ "results": results,
2422
+ })
2423
 
2424
 
2425
  @app.route("/api/ai-learn", methods=["POST"])
 
2429
  from self_learning import analyze_and_update
2430
  days = int((request.get_json(silent=True) or {}).get("days", 30))
2431
  result = analyze_and_update(days=days)
2432
+ pruned = 0
2433
+ if result.get("status") != "insufficient_data":
2434
+ pruned = db.prune_validated_snapshots()
2435
  result["pruned"] = pruned
2436
  return jsonify(result)
2437
  except Exception as e:
 
2454
  days = request.args.get("days", "30", type=int)
2455
  summary = db.get_validation_summary(days=days)
2456
  history = db.get_validation_history(limit=200)
2457
+
2458
+ # Always merge DB records with JSON records (pruned records live in learnings.json).
2459
+ # DB records are authoritative for rows that still exist; JSON fills the rest.
2460
+ try:
2461
+ import self_learning
2462
+ ldata = self_learning._read() or {}
2463
+ json_records = ldata.get("records", [])
2464
+ if json_records:
2465
+ db_ids = {r["id"] for r in history}
2466
+ combined = list(history) + [r for r in json_records if r.get("id") not in db_ids]
2467
+ combined.sort(key=lambda x: x.get("validated_at") or "", reverse=True)
2468
+ history = combined[:200]
2469
+ except Exception:
2470
+ pass
2471
+
2472
  return _json_no_store({"summary": summary, "history": history})
2473
 
2474
 
2475
  @app.route("/api/validation/revalidate-all", methods=["POST"])
2476
  def validation_revalidate_all():
2477
+ """Re-run validation for all previously validated snapshots using correct historical prices.
2478
+ Covers both DB rows and JSON-only records (rows pruned from DB but preserved in learnings.json).
2479
+ """
2480
+ db_history = db.get_validation_history(limit=9999)
2481
+
2482
+ # Pull JSON-only records (ids not in DB) so pruned rows can also be revalidated.
2483
+ json_only_records = []
2484
+ try:
2485
+ import self_learning as _sl
2486
+ ldata = _sl._read() or {}
2487
+ db_ids = {r["id"] for r in db_history}
2488
+ json_only_records = [r for r in ldata.get("records", []) if r.get("id") not in db_ids]
2489
+ except Exception:
2490
+ pass
2491
+
2492
  revalidated = 0
2493
  results = []
2494
+ updated_json_records = {} # id -> updated record (for JSON-only rows)
 
 
 
 
 
 
2495
 
2496
+ def _revalidate_snap(snap, is_json_only=False):
2497
+ nonlocal revalidated
2498
+ snapshot_id = snap.get("id")
2499
+ ticker = snap.get("ticker")
2500
+ entry_price = snap.get("current_price", 0)
2501
+ direction = snap.get("direction", "NEUTRAL")
2502
+ target_date_str = snap.get("validation_target_date")
2503
 
2504
+ if (direction or "").upper() in ("NO TRADE", "N/A"):
2505
+ if not is_json_only:
2506
+ db.mark_prediction_skipped(snapshot_id)
2507
+ return
2508
+ if not entry_price or entry_price <= 0:
2509
+ return
 
 
 
 
 
 
 
2510
 
2511
+ created_at_str = (snap.get("created_at") or "")[:10]
2512
+ try:
2513
+ window_start_str = (
2514
+ datetime.strptime(created_at_str, "%Y-%m-%d") + timedelta(days=1)
2515
+ ).strftime("%Y-%m-%d")
2516
+ except ValueError:
2517
+ window_start_str = target_date_str
2518
+
2519
+ window_high, window_low, actual_price = _fetch_price_window(
2520
+ ticker, window_start_str, target_date_str
2521
+ )
2522
+ if not actual_price:
2523
+ return
2524
 
2525
+ actual_return = round((actual_price - entry_price) / entry_price * 100, 2)
2526
+ target_price_lo = snap.get("target_price_lo") or 0
2527
+ target_price_hi = snap.get("target_price_hi") or 0
 
 
 
 
2528
 
2529
+ hit = _intraday_target_hit(
2530
+ direction, window_high, window_low, actual_price,
2531
+ target_price_lo, target_price_hi, entry_price,
2532
+ )
2533
+ if hit is None:
2534
+ return
2535
+
2536
+ validation_result = "HIT" if hit else "MISS"
2537
+ from datetime import timezone
2538
+ validated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
2539
+
2540
+ if is_json_only:
2541
+ # Can't update DB row (pruned); update the in-memory record for learnings.json.
2542
+ updated = dict(snap)
2543
+ updated.update({
2544
+ "actual_price_at_validation": actual_price,
2545
+ "actual_return_at_validation": actual_return,
2546
+ "window_high": window_high,
2547
+ "window_low": window_low,
2548
+ "validation_result": validation_result,
2549
+ "validated_at": validated_at,
2550
+ })
2551
+ updated_json_records[snapshot_id] = updated
2552
+ else:
2553
  db.validate_prediction(
2554
  snapshot_id=snapshot_id,
2555
  actual_price=actual_price,
2556
  actual_return=actual_return,
2557
+ target_hit=hit,
2558
  window_high=window_high,
2559
  window_low=window_low,
2560
  )
2561
+
2562
+ results.append({
2563
+ "ticker": ticker,
2564
+ "timeframe": snap.get("timeframe"),
2565
+ "direction": direction,
2566
+ "window_high": window_high,
2567
+ "window_low": window_low,
2568
+ "actual_return": actual_return,
2569
+ "target_hit": hit,
2570
+ "source": "json" if is_json_only else "db",
2571
+ })
2572
+ revalidated += 1
2573
+
2574
+ for snap in db_history:
2575
+ try:
2576
+ _revalidate_snap(snap, is_json_only=False)
2577
  except Exception as e:
2578
+ logging.warning(f"revalidate-all DB error for snapshot {snap.get('id')}: {e}")
2579
+
2580
+ for snap in json_only_records:
2581
+ try:
2582
+ _revalidate_snap(snap, is_json_only=True)
2583
+ except Exception as e:
2584
+ logging.warning(f"revalidate-all JSON error for snapshot {snap.get('id')}: {e}")
2585
+
2586
+ # Write updated JSON records back to learnings.json.
2587
+ if updated_json_records:
2588
+ try:
2589
+ import self_learning as _sl
2590
+ ldata = _sl._read() or {}
2591
+ existing = ldata.get("records", [])
2592
+ merged = [updated_json_records.get(r["id"], r) if r.get("id") in updated_json_records else r
2593
+ for r in existing]
2594
+ merged.sort(key=lambda x: x.get("validated_at") or "", reverse=True)
2595
+ ldata["records"] = merged[:500]
2596
+ _sl._write(ldata)
2597
+ except Exception as e:
2598
+ logging.warning(f"revalidate-all: failed to write JSON records: {e}")
2599
+
2600
  return _json_no_store({"revalidated": revalidated, "results": results})
2601
 
2602
 
 
2729
  threading.Thread(target=_run, daemon=True, name="trade-monitor").start()
2730
 
2731
 
2732
+ def _start_validation_scheduler():
2733
+ """Background thread: auto-run pending validations after NSE market close (3:45pm IST) daily.
2734
+
2735
+ Polls every 5 minutes. Tracks which calendar date it last ran so it only
2736
+ fires once per trading day regardless of how many times the poll fires.
2737
+ """
2738
+ import threading, time as _time
2739
+
2740
+ _IST = timezone(timedelta(hours=5, minutes=30))
2741
+ POLL_SECS = 300 # check every 5 minutes
2742
+
2743
+ def _run():
2744
+ last_ran_date = None
2745
+ _time.sleep(15) # let Flask finish binding before starting
2746
+ while True:
2747
+ try:
2748
+ now_ist = datetime.now(timezone.utc).astimezone(_IST)
2749
+ today = now_ist.date()
2750
+
2751
+ # Only fire on trading days, after 15:45 IST, and at most once per day.
2752
+ if (
2753
+ last_ran_date != today
2754
+ and nse_is_trading_day(today)
2755
+ and (now_ist.hour, now_ist.minute) >= (16, 30)
2756
+ ):
2757
+ due_count = db.get_validation_pending_count(due_only=True)
2758
+ if due_count > 0:
2759
+ app.logger.info(
2760
+ "Validation scheduler: %d items due — running auto-execute", due_count
2761
+ )
2762
+ # Import and reuse the same execute logic inline to avoid an HTTP round-trip.
2763
+ from self_learning import analyze_and_update
2764
+ pending = db.get_validation_pending(limit=500, due_only=True)
2765
+ actionable = [
2766
+ s for s in pending
2767
+ if (s.get("direction") or "").upper() not in ("NO TRADE", "N/A")
2768
+ and (s.get("current_price") or 0) > 0
2769
+ ]
2770
+ validated = 0
2771
+ hits = 0
2772
+ misses = 0
2773
+ today_str = today.isoformat()
2774
+ with ThreadPoolExecutor(max_workers=min(len(actionable), 10) or 1) as pool:
2775
+ def _fw(snap):
2776
+ tf = (snap.get("timeframe") or "").upper()
2777
+ tgt = snap.get("validation_target_date")
2778
+ try:
2779
+ if tf == "INTRADAY":
2780
+ return snap, *_fetch_intraday_window_capped(snap["ticker"], tgt)
2781
+ created = (snap.get("created_at") or "")[:10]
2782
+ try:
2783
+ ws = (datetime.strptime(created, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
2784
+ except ValueError:
2785
+ ws = tgt
2786
+ wh, wl, ap = _fetch_price_window(snap["ticker"], ws, tgt)
2787
+ if ap is None and tgt == today_str:
2788
+ wh2, wl2, ap2 = _fetch_intraday_window_capped(snap["ticker"], tgt, cutoff_hour=15, cutoff_minute=30)
2789
+ if ap2 is None:
2790
+ from data_sources import fetch_live_price as _flp
2791
+ live = _flp(snap["ticker"], allow_delayed=True)
2792
+ if live and live > 0:
2793
+ ap2, wh2, wl2 = live, (wh2 or live), (wl2 or live)
2794
+ wh, wl, ap = (wh2 or wh), (wl2 or wl), ap2
2795
+ return snap, wh, wl, ap
2796
+ except Exception as exc:
2797
+ app.logger.warning("scheduler _fw(%s): %s", snap.get("ticker"), exc)
2798
+ return snap, None, None, None
2799
+
2800
+ for snap, wh, wl, ap in pool.map(_fw, actionable):
2801
+ try:
2802
+ if not ap:
2803
+ continue
2804
+ ep = snap.get("current_price", 0)
2805
+ ar = round((ap - ep) / ep * 100, 2)
2806
+ tlo = snap.get("target_price_lo") or 0
2807
+ thi = snap.get("target_price_hi") or 0
2808
+ direction = (snap.get("direction") or "NEUTRAL").upper()
2809
+ hit = _intraday_target_hit(direction, wh, wl, ap, tlo, thi, ep)
2810
+ if hit is None:
2811
+ db.mark_prediction_skipped(snap.get("id"))
2812
+ continue
2813
+ db.validate_prediction(snap.get("id"), ap, ar, hit, wh, wl)
2814
+ validated += 1
2815
+ if hit:
2816
+ hits += 1
2817
+ else:
2818
+ misses += 1
2819
+ except Exception as exc:
2820
+ app.logger.warning("scheduler validate(%s): %s", snap.get("id"), exc)
2821
+
2822
+ app.logger.info(
2823
+ "Validation scheduler done: %d validated (%d HIT, %d MISS)",
2824
+ validated, hits, misses,
2825
+ )
2826
+
2827
+ if validated > 0:
2828
+ try:
2829
+ learn_result = analyze_and_update(days=30)
2830
+ if learn_result.get("status") != "insufficient_data":
2831
+ pruned = db.prune_validated_snapshots()
2832
+ if pruned:
2833
+ app.logger.info("Scheduler pruned %d validated snapshots", pruned)
2834
+ except Exception as le:
2835
+ app.logger.warning("Scheduler self-learning update failed: %s", le)
2836
+
2837
+ last_ran_date = today
2838
+
2839
+ except Exception as exc:
2840
+ app.logger.warning("Validation scheduler error: %s", exc)
2841
+
2842
+ _time.sleep(POLL_SECS)
2843
+
2844
+ threading.Thread(target=_run, daemon=True, name="validation-scheduler").start()
2845
+
2846
+
2847
  # Start background services at import time so both `python app.py` and
2848
  # WSGI servers (gunicorn) warm the top5 cache and run the trade monitor.
2849
  _prewarm_top5()
2850
  _start_trade_monitor()
2851
+ _start_validation_scheduler()
2852
 
2853
  if __name__ == "__main__":
2854
  port = int(os.environ.get("PORT", 7860))
self_learning.py CHANGED
@@ -43,6 +43,9 @@ def analyze_and_update(days: int = 30) -> dict:
43
  merged_conf: dict = {}
44
  historical_total = 0
45
  historical_hits = 0
 
 
 
46
 
47
  if is_existing_valid:
48
  for k, v in existing.get("buckets", {}).items():
@@ -61,6 +64,7 @@ def analyze_and_update(days: int = 30) -> dict:
61
 
62
  # Merge new DB rows on top of accumulated counts.
63
  new_hits = 0
 
64
  for r in new_validated:
65
  direction = r["direction"].upper()
66
  timeframe = (r.get("timeframe") or "1D").upper()
@@ -80,6 +84,34 @@ def analyze_and_update(days: int = 30) -> dict:
80
 
81
  new_hits += int(hit)
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  total = historical_total + len(new_validated)
84
 
85
  if total < _MIN_SAMPLES:
@@ -89,6 +121,7 @@ def analyze_and_update(days: int = 30) -> dict:
89
  "new_in_this_run": len(new_validated),
90
  "min_required": _MIN_SAMPLES,
91
  "calibration_notes": [],
 
92
  }
93
  _write(result)
94
  return result
@@ -155,6 +188,7 @@ def analyze_and_update(days: int = 30) -> dict:
155
  k: {"hits": v["hits"], "total": v["total"], "hit_rate": round(v["hits"] / v["total"], 3)}
156
  for k, v in merged_conf.items() if v["total"] >= 5
157
  },
 
158
  }
159
  _write(result)
160
  logging.info(
 
43
  merged_conf: dict = {}
44
  historical_total = 0
45
  historical_hits = 0
46
+ # Accumulated individual records from prior prune cycles (dedup by id).
47
+ existing_records: list = existing.get("records", [])
48
+ existing_record_ids: set = {r["id"] for r in existing_records if r.get("id")}
49
 
50
  if is_existing_valid:
51
  for k, v in existing.get("buckets", {}).items():
 
64
 
65
  # Merge new DB rows on top of accumulated counts.
66
  new_hits = 0
67
+ new_records = []
68
  for r in new_validated:
69
  direction = r["direction"].upper()
70
  timeframe = (r.get("timeframe") or "1D").upper()
 
84
 
85
  new_hits += int(hit)
86
 
87
+ # Keep a slimmed individual record for history tab fallback after DB pruning.
88
+ if r.get("id") and r["id"] not in existing_record_ids:
89
+ new_records.append({
90
+ "id": r["id"],
91
+ "ticker": r.get("ticker"),
92
+ "timeframe": r.get("timeframe"),
93
+ "direction": r.get("direction"),
94
+ "confidence": r.get("confidence"),
95
+ "target_price_lo": r.get("target_price_lo"),
96
+ "target_price_hi": r.get("target_price_hi"),
97
+ "predicted_return_lo": r.get("predicted_return_lo"),
98
+ "predicted_return_hi": r.get("predicted_return_hi"),
99
+ "current_price": r.get("current_price"),
100
+ "actual_price_at_validation": r.get("actual_price_at_validation"),
101
+ "actual_return_at_validation": r.get("actual_return_at_validation"),
102
+ "window_high": r.get("window_high"),
103
+ "window_low": r.get("window_low"),
104
+ "validation_result": r.get("validation_result"),
105
+ "created_at": r.get("created_at"),
106
+ "validated_at": r.get("validated_at"),
107
+ "validation_target_date": r.get("validation_target_date"),
108
+ })
109
+
110
+ # Merge new records with existing, sort newest-validated first, cap at 500.
111
+ all_records = new_records + existing_records
112
+ all_records.sort(key=lambda x: x.get("validated_at") or "", reverse=True)
113
+ all_records = all_records[:500]
114
+
115
  total = historical_total + len(new_validated)
116
 
117
  if total < _MIN_SAMPLES:
 
121
  "new_in_this_run": len(new_validated),
122
  "min_required": _MIN_SAMPLES,
123
  "calibration_notes": [],
124
+ "records": all_records,
125
  }
126
  _write(result)
127
  return result
 
188
  k: {"hits": v["hits"], "total": v["total"], "hit_rate": round(v["hits"] / v["total"], 3)}
189
  for k, v in merged_conf.items() if v["total"] >= 5
190
  },
191
+ "records": all_records,
192
  }
193
  _write(result)
194
  logging.info(
static/app.js CHANGED
@@ -2422,11 +2422,13 @@ async function loadValidation() {
2422
  const execData = await execRes.json();
2423
  autoValidated = execData.validated || 0;
2424
  if (autoValidated > 0) {
 
 
2425
  const toast = document.createElement('div');
2426
  toast.className = 'val-toast';
2427
- toast.textContent = `✓ Auto-validated ${autoValidated} prediction${autoValidated !== 1 ? 's' : ''}`;
2428
  document.body.appendChild(toast);
2429
- setTimeout(() => toast.remove(), 4000);
2430
  // Reload both pending and summary so stat cards reflect new validations
2431
  const [summRes2, pendRes2] = await Promise.all([
2432
  fetch('/api/validation/summary', { cache: 'no-store' }),
 
2422
  const execData = await execRes.json();
2423
  autoValidated = execData.validated || 0;
2424
  if (autoValidated > 0) {
2425
+ const hits = execData.hits ?? 0;
2426
+ const misses = execData.misses ?? 0;
2427
  const toast = document.createElement('div');
2428
  toast.className = 'val-toast';
2429
+ toast.innerHTML = `✓ Validated ${autoValidated}: <span class="val-toast-hit">${hits} HIT</span> · <span class="val-toast-miss">${misses} MISS</span>`;
2430
  document.body.appendChild(toast);
2431
+ setTimeout(() => toast.remove(), 6000);
2432
  // Reload both pending and summary so stat cards reflect new validations
2433
  const [summRes2, pendRes2] = await Promise.all([
2434
  fetch('/api/validation/summary', { cache: 'no-store' }),
static/style.css CHANGED
@@ -815,10 +815,12 @@ input::placeholder { color: var(--text-dim); }
815
  .val-toast {
816
  position: fixed; bottom: 24px; right: 24px; z-index: 9999;
817
  background: var(--bg2); border: 1px solid var(--green); border-radius: var(--radius);
818
- color: var(--green); font-size: 13px; font-weight: 600;
819
  padding: 10px 18px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
820
  animation: fadeInUp 0.3s ease;
821
  }
 
 
822
  @keyframes fadeInUp {
823
  from { opacity: 0; transform: translateY(12px); }
824
  to { opacity: 1; transform: translateY(0); }
 
815
  .val-toast {
816
  position: fixed; bottom: 24px; right: 24px; z-index: 9999;
817
  background: var(--bg2); border: 1px solid var(--green); border-radius: var(--radius);
818
+ color: var(--text); font-size: 13px; font-weight: 600;
819
  padding: 10px 18px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
820
  animation: fadeInUp 0.3s ease;
821
  }
822
+ .val-toast-hit { color: var(--green); }
823
+ .val-toast-miss { color: var(--red, #e55); }
824
  @keyframes fadeInUp {
825
  from { opacity: 0; transform: translateY(12px); }
826
  to { opacity: 1; transform: translateY(0); }