Spaces:
Running
fix: SLIGHTLY BULLISH validation + scoring — treat as directional, not NEUTRAL
Browse files- database.py: _calibrated_snap_range now maps SLIGHTLY BULLISH to BULLISH ranges
(0.02–0.45%) instead of falling through to NEUTRAL (±5.1%), so snapshots are
measured as directional predictions
- app.py: _intraday_target_hit validates SLIGHTLY BULLISH same as BULLISH (requires
window_high >= target_price_lo); also fixes NEUTRAL logic to use intraday midpoint
touch instead of close-price-within-band (aligns with backtest evaluation)
- top5_picker.py: apply 0.65× score penalty for SLIGHTLY BULLISH in both _score_5d
and _score_1w — still accepted when no pure BULLISH picks exist, but never displaces
them for the same confidence/ML setup
- static/app.js: auto-validation runs before stat cards render (stats always show
post-validation numbers); loading spinner added; top5 loads in parallel on dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py +8 -6
- database.py +1 -1
- static/app.js +50 -40
- top5_picker.py +6 -0
|
@@ -2038,7 +2038,7 @@ def _intraday_target_hit(direction: str, window_high, window_low, close_price,
|
|
| 2038 |
|
| 2039 |
BULLISH: HIT if window_high reached target_price_lo (min bull target) — stock doesn't need to close there
|
| 2040 |
BEARISH: HIT if window_low reached target_price_hi (mildest bear target)
|
| 2041 |
-
NEUTRAL: HIT if
|
| 2042 |
NO TRADE / N/A / missing range: returns None (skip)
|
| 2043 |
"""
|
| 2044 |
direction = (direction or "").upper()
|
|
@@ -2052,7 +2052,7 @@ def _intraday_target_hit(direction: str, window_high, window_low, close_price,
|
|
| 2052 |
if target_price_lo == target_price_hi:
|
| 2053 |
return None
|
| 2054 |
|
| 2055 |
-
if direction
|
| 2056 |
if window_high is None:
|
| 2057 |
return None
|
| 2058 |
return window_high >= target_price_lo
|
|
@@ -2060,11 +2060,13 @@ def _intraday_target_hit(direction: str, window_high, window_low, close_price,
|
|
| 2060 |
if window_low is None:
|
| 2061 |
return None
|
| 2062 |
return window_low <= target_price_hi
|
| 2063 |
-
# NEUTRAL:
|
| 2064 |
-
|
| 2065 |
-
|
|
|
|
| 2066 |
return None
|
| 2067 |
-
|
|
|
|
| 2068 |
|
| 2069 |
|
| 2070 |
@app.route("/api/validation/execute", methods=["POST"])
|
|
|
|
| 2038 |
|
| 2039 |
BULLISH: HIT if window_high reached target_price_lo (min bull target) — stock doesn't need to close there
|
| 2040 |
BEARISH: HIT if window_low reached target_price_hi (mildest bear target)
|
| 2041 |
+
NEUTRAL: HIT if intraday range touched the midpoint (≈ entry price) during window
|
| 2042 |
NO TRADE / N/A / missing range: returns None (skip)
|
| 2043 |
"""
|
| 2044 |
direction = (direction or "").upper()
|
|
|
|
| 2052 |
if target_price_lo == target_price_hi:
|
| 2053 |
return None
|
| 2054 |
|
| 2055 |
+
if direction in ("BULLISH", "SLIGHTLY BULLISH"):
|
| 2056 |
if window_high is None:
|
| 2057 |
return None
|
| 2058 |
return window_high >= target_price_lo
|
|
|
|
| 2060 |
if window_low is None:
|
| 2061 |
return None
|
| 2062 |
return window_low <= target_price_hi
|
| 2063 |
+
# NEUTRAL: HIT if stock's intraday range touched the midpoint (≈ entry price).
|
| 2064 |
+
# Aligns with backtest evaluation: midpoint touched intraday. Misses only when
|
| 2065 |
+
# the stock gapped away from entry and never returned during the window.
|
| 2066 |
+
if window_high is None or window_low is None:
|
| 2067 |
return None
|
| 2068 |
+
mid = (target_price_lo + target_price_hi) / 2.0
|
| 2069 |
+
return window_high >= mid and window_low <= mid
|
| 2070 |
|
| 2071 |
|
| 2072 |
@app.route("/api/validation/execute", methods=["POST"])
|
|
@@ -28,7 +28,7 @@ def _calibrated_snap_range(direction: str, timeframe: str, current_price: float)
|
|
| 28 |
"""Return (target_price_lo, target_price_hi) using calibrated % ranges."""
|
| 29 |
d = (direction or "NEUTRAL").upper()
|
| 30 |
tf = timeframe if timeframe in _SNAP_BULL else "1D"
|
| 31 |
-
if d
|
| 32 |
lo_pct, hi_pct = _SNAP_BULL[tf]
|
| 33 |
elif d == "BEARISH":
|
| 34 |
lo_pct, hi_pct = _SNAP_BEAR[tf]
|
|
|
|
| 28 |
"""Return (target_price_lo, target_price_hi) using calibrated % ranges."""
|
| 29 |
d = (direction or "NEUTRAL").upper()
|
| 30 |
tf = timeframe if timeframe in _SNAP_BULL else "1D"
|
| 31 |
+
if d in ("BULLISH", "SLIGHTLY BULLISH"):
|
| 32 |
lo_pct, hi_pct = _SNAP_BULL[tf]
|
| 33 |
elif d == "BEARISH":
|
| 34 |
lo_pct, hi_pct = _SNAP_BEAR[tf]
|
|
@@ -875,6 +875,12 @@ function hideEl(id) { const e = document.getElementById(id); if (e) { e.classLis
|
|
| 875 |
|
| 876 |
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
| 877 |
async function loadDashboard() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
// Portfolio summary
|
| 879 |
try {
|
| 880 |
const res = await fetch('/api/portfolio', { cache: 'no-store' });
|
|
@@ -903,12 +909,6 @@ async function loadDashboard() {
|
|
| 903 |
}
|
| 904 |
} catch (e) { console.warn(e); }
|
| 905 |
|
| 906 |
-
// Top 5 preview (load only if not already loaded)
|
| 907 |
-
const cardsEl = document.getElementById('dash-top5-cards');
|
| 908 |
-
if (!cardsEl.hasAttribute('data-loaded')) {
|
| 909 |
-
loadTop5Cards('dash-top5-cards', 'dash-top5-loading', 3);
|
| 910 |
-
}
|
| 911 |
-
|
| 912 |
}
|
| 913 |
|
| 914 |
document.getElementById('dash-refresh-top5')?.addEventListener('click', () => {
|
|
@@ -2306,17 +2306,52 @@ async function loadValidation() {
|
|
| 2306 |
const missBreakdown = document.getElementById('validation-miss-breakdown');
|
| 2307 |
const missList = document.getElementById('validation-miss-list');
|
| 2308 |
|
|
|
|
| 2309 |
try {
|
| 2310 |
// Load summary and history
|
| 2311 |
-
|
| 2312 |
-
const summData = await summRes.json();
|
| 2313 |
|
| 2314 |
// Load pending
|
| 2315 |
const pendRes = await fetch('/api/validation/pending', { cache: 'no-store' });
|
| 2316 |
const pendData = await pendRes.json();
|
| 2317 |
const pendingList = pendData.pending || [];
|
| 2318 |
const dueCount = pendData.due_count ?? 0;
|
| 2319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2320 |
|
| 2321 |
// Update summary stats — show directional hit rate (BULLISH+BEARISH) as headline,
|
| 2322 |
// all-predictions as a footnote so NEUTRAL misses don't bury the signal quality.
|
|
@@ -2341,40 +2376,13 @@ async function loadValidation() {
|
|
| 2341 |
}
|
| 2342 |
});
|
| 2343 |
|
| 2344 |
-
// Pending stat card —
|
| 2345 |
const pendingEl = document.getElementById('vstat-pending');
|
| 2346 |
const pendingSubEl = document.getElementById('vstat-pending-sub');
|
| 2347 |
-
if (pendingEl) pendingEl.textContent =
|
| 2348 |
if (pendingSubEl) {
|
| 2349 |
-
|
| 2350 |
-
|
| 2351 |
-
pendingSubEl.style.color = '#f59e0b';
|
| 2352 |
-
} else {
|
| 2353 |
-
pendingSubEl.textContent = `${pendingList.length} upcoming`;
|
| 2354 |
-
pendingSubEl.style.color = '';
|
| 2355 |
-
}
|
| 2356 |
-
}
|
| 2357 |
-
|
| 2358 |
-
// Auto-execute validation if any items are due today
|
| 2359 |
-
if (dueCount > 0) {
|
| 2360 |
-
try {
|
| 2361 |
-
const execRes = await fetch('/api/validation/execute', { method: 'POST', cache: 'no-store' });
|
| 2362 |
-
if (execRes.ok) {
|
| 2363 |
-
const execData = await execRes.json();
|
| 2364 |
-
if (execData.validated > 0) {
|
| 2365 |
-
const toast = document.createElement('div');
|
| 2366 |
-
toast.className = 'val-toast';
|
| 2367 |
-
toast.textContent = `✓ Auto-validated ${execData.validated} prediction${execData.validated !== 1 ? 's' : ''}`;
|
| 2368 |
-
document.body.appendChild(toast);
|
| 2369 |
-
setTimeout(() => toast.remove(), 4000);
|
| 2370 |
-
// Reload data after auto-execution
|
| 2371 |
-
const pendRes2 = await fetch('/api/validation/pending', { cache: 'no-store' });
|
| 2372 |
-
const pendData2 = await pendRes2.json();
|
| 2373 |
-
pendingList.length = 0;
|
| 2374 |
-
(pendData2.pending || []).forEach(p => pendingList.push(p));
|
| 2375 |
-
}
|
| 2376 |
-
}
|
| 2377 |
-
} catch (_) {}
|
| 2378 |
}
|
| 2379 |
|
| 2380 |
const todayStr = new Date().toISOString().slice(0, 10);
|
|
@@ -2455,6 +2463,8 @@ async function loadValidation() {
|
|
| 2455 |
} catch (e) {
|
| 2456 |
console.warn('Validation load failed:', e);
|
| 2457 |
if (pending) pending.innerHTML = `<div class="error-state">Error loading validation data: ${e.message}</div>`;
|
|
|
|
|
|
|
| 2458 |
}
|
| 2459 |
}
|
| 2460 |
|
|
|
|
| 875 |
|
| 876 |
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
| 877 |
async function loadDashboard() {
|
| 878 |
+
// Top 5 preview — kick off immediately so it runs in parallel with portfolio/trades fetches
|
| 879 |
+
const cardsEl = document.getElementById('dash-top5-cards');
|
| 880 |
+
if (!cardsEl.hasAttribute('data-loaded')) {
|
| 881 |
+
loadTop5Cards('dash-top5-cards', 'dash-top5-loading', 3);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
// Portfolio summary
|
| 885 |
try {
|
| 886 |
const res = await fetch('/api/portfolio', { cache: 'no-store' });
|
|
|
|
| 909 |
}
|
| 910 |
} catch (e) { console.warn(e); }
|
| 911 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
}
|
| 913 |
|
| 914 |
document.getElementById('dash-refresh-top5')?.addEventListener('click', () => {
|
|
|
|
| 2306 |
const missBreakdown = document.getElementById('validation-miss-breakdown');
|
| 2307 |
const missList = document.getElementById('validation-miss-list');
|
| 2308 |
|
| 2309 |
+
showEl('validation-loading');
|
| 2310 |
try {
|
| 2311 |
// Load summary and history
|
| 2312 |
+
let summData = await (await fetch('/api/validation/summary', { cache: 'no-store' })).json();
|
|
|
|
| 2313 |
|
| 2314 |
// Load pending
|
| 2315 |
const pendRes = await fetch('/api/validation/pending', { cache: 'no-store' });
|
| 2316 |
const pendData = await pendRes.json();
|
| 2317 |
const pendingList = pendData.pending || [];
|
| 2318 |
const dueCount = pendData.due_count ?? 0;
|
| 2319 |
+
|
| 2320 |
+
// Auto-execute validation if any items are due today — do this BEFORE rendering
|
| 2321 |
+
// stats so the cards always show post-validation numbers.
|
| 2322 |
+
let autoValidated = 0;
|
| 2323 |
+
if (dueCount > 0) {
|
| 2324 |
+
try {
|
| 2325 |
+
const execRes = await fetch('/api/validation/execute', { method: 'POST', cache: 'no-store' });
|
| 2326 |
+
if (execRes.ok) {
|
| 2327 |
+
const execData = await execRes.json();
|
| 2328 |
+
autoValidated = execData.validated || 0;
|
| 2329 |
+
if (autoValidated > 0) {
|
| 2330 |
+
const toast = document.createElement('div');
|
| 2331 |
+
toast.className = 'val-toast';
|
| 2332 |
+
toast.textContent = `✓ Auto-validated ${autoValidated} prediction${autoValidated !== 1 ? 's' : ''}`;
|
| 2333 |
+
document.body.appendChild(toast);
|
| 2334 |
+
setTimeout(() => toast.remove(), 4000);
|
| 2335 |
+
// Reload both pending and summary so stat cards reflect new validations
|
| 2336 |
+
const [summRes2, pendRes2] = await Promise.all([
|
| 2337 |
+
fetch('/api/validation/summary', { cache: 'no-store' }),
|
| 2338 |
+
fetch('/api/validation/pending', { cache: 'no-store' }),
|
| 2339 |
+
]);
|
| 2340 |
+
summData = await summRes2.json();
|
| 2341 |
+
const pendData2 = await pendRes2.json();
|
| 2342 |
+
pendingList.length = 0;
|
| 2343 |
+
(pendData2.pending || []).forEach(p => pendingList.push(p));
|
| 2344 |
+
// If pending queue is now empty, switch to History tab
|
| 2345 |
+
if (pendingList.length === 0) {
|
| 2346 |
+
document.querySelectorAll('.vtab').forEach(t => t.classList.remove('active'));
|
| 2347 |
+
document.querySelectorAll('.vtab-content').forEach(c => c.classList.remove('active'));
|
| 2348 |
+
document.querySelector('.vtab[data-vtab="history"]')?.classList.add('active');
|
| 2349 |
+
document.getElementById('vtab-history')?.classList.add('active');
|
| 2350 |
+
}
|
| 2351 |
+
}
|
| 2352 |
+
}
|
| 2353 |
+
} catch (_) {}
|
| 2354 |
+
}
|
| 2355 |
|
| 2356 |
// Update summary stats — show directional hit rate (BULLISH+BEARISH) as headline,
|
| 2357 |
// all-predictions as a footnote so NEUTRAL misses don't bury the signal quality.
|
|
|
|
| 2376 |
}
|
| 2377 |
});
|
| 2378 |
|
| 2379 |
+
// Pending stat card — after auto-validation, reflect the refreshed count
|
| 2380 |
const pendingEl = document.getElementById('vstat-pending');
|
| 2381 |
const pendingSubEl = document.getElementById('vstat-pending-sub');
|
| 2382 |
+
if (pendingEl) pendingEl.textContent = pendingList.length;
|
| 2383 |
if (pendingSubEl) {
|
| 2384 |
+
pendingSubEl.textContent = `${pendingList.length} upcoming`;
|
| 2385 |
+
pendingSubEl.style.color = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2386 |
}
|
| 2387 |
|
| 2388 |
const todayStr = new Date().toISOString().slice(0, 10);
|
|
|
|
| 2463 |
} catch (e) {
|
| 2464 |
console.warn('Validation load failed:', e);
|
| 2465 |
if (pending) pending.innerHTML = `<div class="error-state">Error loading validation data: ${e.message}</div>`;
|
| 2466 |
+
} finally {
|
| 2467 |
+
hideEl('validation-loading');
|
| 2468 |
}
|
| 2469 |
}
|
| 2470 |
|
|
@@ -154,6 +154,10 @@ def get_top5_picks(
|
|
| 154 |
sector_factor = 1.0
|
| 155 |
|
| 156 |
score = base_ret * conf_mult * ml_factor * rr_factor * sector_factor
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
# When AI is unavailable, apply a heavy penalty — signal-strong stocks still
|
| 158 |
# surface but rank below LLM-confirmed ones.
|
| 159 |
if p.get("no_trade_reason") == "ai_unavailable":
|
|
@@ -394,6 +398,8 @@ def get_weekly_picks(
|
|
| 394 |
sector_data = p.get("sector") or {}
|
| 395 |
sector_factor = 1.12 if sector_data.get("leading") else (0.90 if sector_data.get("lagging") else 1.0)
|
| 396 |
score = ret_hi_val * conf_mult * ml_factor * rr_factor * sector_factor
|
|
|
|
|
|
|
| 397 |
if p.get("no_trade_reason") == "ai_unavailable":
|
| 398 |
score *= 0.40
|
| 399 |
return score
|
|
|
|
| 154 |
sector_factor = 1.0
|
| 155 |
|
| 156 |
score = base_ret * conf_mult * ml_factor * rr_factor * sector_factor
|
| 157 |
+
# SLIGHTLY BULLISH = downgraded from BULLISH (bear-market Nifty gate or weak signals).
|
| 158 |
+
# Apply a 0.65× penalty so genuine BULLISH picks always rank higher for the same setup.
|
| 159 |
+
if p.get("direction") == "SLIGHTLY BULLISH":
|
| 160 |
+
score *= 0.65
|
| 161 |
# When AI is unavailable, apply a heavy penalty — signal-strong stocks still
|
| 162 |
# surface but rank below LLM-confirmed ones.
|
| 163 |
if p.get("no_trade_reason") == "ai_unavailable":
|
|
|
|
| 398 |
sector_data = p.get("sector") or {}
|
| 399 |
sector_factor = 1.12 if sector_data.get("leading") else (0.90 if sector_data.get("lagging") else 1.0)
|
| 400 |
score = ret_hi_val * conf_mult * ml_factor * rr_factor * sector_factor
|
| 401 |
+
if p.get("direction") == "SLIGHTLY BULLISH":
|
| 402 |
+
score *= 0.65
|
| 403 |
if p.get("no_trade_reason") == "ai_unavailable":
|
| 404 |
score *= 0.40
|
| 405 |
return score
|