Spaces:
Sleeping
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>
- app.py +257 -56
- self_learning.py +34 -0
- static/app.js +4 -2
- static/style.css +3 -1
|
@@ -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 |
-
|
| 2407 |
-
|
| 2408 |
-
|
|
|
|
| 2409 |
except Exception as _le:
|
| 2410 |
app.logger.warning("Self-learning update failed: %s", _le)
|
| 2411 |
|
| 2412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2453 |
revalidated = 0
|
| 2454 |
results = []
|
| 2455 |
-
|
| 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 |
-
|
| 2464 |
-
|
| 2465 |
-
|
| 2466 |
-
|
| 2467 |
-
|
|
|
|
|
|
|
| 2468 |
|
| 2469 |
-
|
| 2470 |
-
|
| 2471 |
-
|
| 2472 |
-
|
| 2473 |
-
|
| 2474 |
-
|
| 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 |
-
|
| 2484 |
-
|
| 2485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2486 |
|
| 2487 |
-
|
| 2488 |
-
|
| 2489 |
-
|
| 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=
|
| 2500 |
window_high=window_high,
|
| 2501 |
window_low=window_low,
|
| 2502 |
)
|
| 2503 |
-
|
| 2504 |
-
|
| 2505 |
-
|
| 2506 |
-
|
| 2507 |
-
|
| 2508 |
-
|
| 2509 |
-
|
| 2510 |
-
|
| 2511 |
-
|
| 2512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -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(
|
|
@@ -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.
|
| 2428 |
document.body.appendChild(toast);
|
| 2429 |
-
setTimeout(() => toast.remove(),
|
| 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' }),
|
|
@@ -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(--
|
| 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); }
|