Khanna, Videh Rakesh Rakesh Claude Sonnet 4.6 commited on
Commit
a947769
·
1 Parent(s): 3c92e9c

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>

Files changed (4) hide show
  1. app.py +8 -6
  2. database.py +1 -1
  3. static/app.js +50 -40
  4. top5_picker.py +6 -0
app.py CHANGED
@@ -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 closing price stayed within [target_price_lo, target_price_hi]
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 == "BULLISH":
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: use close price (stock should end within the neutral band)
2064
- price = close_price if close_price is not None else window_high
2065
- if price is None:
 
2066
  return None
2067
- return target_price_lo <= price <= target_price_hi
 
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"])
database.py CHANGED
@@ -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 == "BULLISH":
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]
static/app.js CHANGED
@@ -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
- const summRes = await fetch('/api/validation/summary', { cache: 'no-store' });
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
- const upcomingCount = (pendData.total_count ?? pendingList.length) - dueCount;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 — show Due Now vs Upcoming split
2345
  const pendingEl = document.getElementById('vstat-pending');
2346
  const pendingSubEl = document.getElementById('vstat-pending-sub');
2347
- if (pendingEl) pendingEl.textContent = dueCount > 0 ? dueCount : pendingList.length;
2348
  if (pendingSubEl) {
2349
- if (dueCount > 0) {
2350
- pendingSubEl.textContent = `${upcomingCount} upcoming`;
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
 
top5_picker.py CHANGED
@@ -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