Wave 4A: Outlook Mode (never-empty grid) + Market-Breadth consensus strip
Step 3 — OUTLOOK MODE. The game grid no longer dead-ends in a "NO SLATE" CTA. When there are no live games (and it's not a network failure) it shows REAL, always-available data: yesterday's PROVEN A-tier receipts (/api/ledger/model) + tomorrow's date-pinned ESPN schedule preview (free/cached). A network fetchError stays a distinct ERROR state — never a fabricated outlook. - lib/outlook.js (new, CommonJS, unit-tested): buildOutlook selection + mapTomorrowPreview (upcoming-only, drops incomplete matchups, never invents). - Slate.tsx: OutlookSurface replaces the empty-grid CTA (dateOffset 0 only). - dashboard/page.tsx: DashboardOutlook replaces the "Today's games" NO-SLATE CTA. Step 4 — MARKET-BREADTH / CONSENSUS vs MODEL. Makes the DeskShowcase "consensus vs model" claim REAL. Consensus = median book line across a prop's per-book rows; the model's position is model_value vs consensus, signed by the graded side. <2 distinct books → null (never fabricate a consensus); a non-numeric line is ignored, never coerced to 0. - lib/marketBreadth.js (new, CommonJS, unit-tested): median/computeBreadth/ collectBreadth (strict null guards). - components/vyndr/MarketBreadth.tsx (new): mono/tabular strip, colored by sign via colorContract.edgeColor, self-hides when nothing has >=2 books. - Slate.tsx renders it above the grid (joins books + snapshot model_value). - slateAdapter.js exports gradeKey for the join. - DeskShowcase.tsx: the consensus claim is now backed by the shipped feature. Tests: tests/unit/outlook.test.js + tests/unit/marketBreadth.test.js (23 cases). Full suite 2984 passing (245 suites); next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// Wave 4A — MARKET-BREADTH / CONSENSUS strip (Step 4). Makes the DeskShowcase
|
||||
// "consensus vs model" claim REAL: the consensus is the MEDIAN book line across
|
||||
// the prop's per-book rows; the model's position is model_value vs that
|
||||
// consensus, signed by the graded side. Doctrine: never fabricate a consensus —
|
||||
// <2 distinct books → null (absent beats invented); a non-numeric line is
|
||||
// ignored, never coerced to 0 (the Number(null)===0 trap).
|
||||
|
||||
const { computeBreadth, collectBreadth, median } = require('../../web/src/lib/marketBreadth');
|
||||
|
||||
describe('median', () => {
|
||||
it('odd length → middle', () => { expect(median([2.5, 1.5, 3.5])).toBe(2.5); });
|
||||
it('even length → mean of the two middles', () => { expect(median([1.5, 2.5])).toBe(2); });
|
||||
it('ignores non-finite, empty → null', () => {
|
||||
expect(median([NaN, 1.5, 2.5, null])).toBe(2);
|
||||
expect(median([])).toBeNull();
|
||||
expect(median(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBreadth — median consensus from ≥2 books', () => {
|
||||
const books = [
|
||||
{ book: 'draftkings', line: 2.5, over_odds: -115 },
|
||||
{ book: 'fanduel', line: 2.5, over_odds: -110 },
|
||||
{ book: 'betmgm', line: 1.5, over_odds: -120 },
|
||||
];
|
||||
|
||||
it('consensus is the median book LINE; model above → OVER edge (positive, green)', () => {
|
||||
const b = computeBreadth(books, 3.0, 'over');
|
||||
expect(b.consensus).toBe(2.5);
|
||||
expect(b.bookCount).toBe(3);
|
||||
expect(b.model).toBe(3.0);
|
||||
expect(b.delta).toBeCloseTo(0.5);
|
||||
expect(b.signedEdge).toBeCloseTo(0.5); // model projects ABOVE market → supports OVER
|
||||
expect(b.position).toBe('above');
|
||||
expect(b.side).toBe('over');
|
||||
});
|
||||
|
||||
it('UNDER read: model BELOW consensus is the edge (signed positive)', () => {
|
||||
const b = computeBreadth(books, 2.0, 'under');
|
||||
expect(b.consensus).toBe(2.5);
|
||||
expect(b.delta).toBeCloseTo(-0.5); // raw model - consensus stays signed to the market
|
||||
expect(b.signedEdge).toBeCloseTo(0.5); // consensus - model, favors UNDER
|
||||
expect(b.position).toBe('below');
|
||||
expect(b.side).toBe('under');
|
||||
});
|
||||
|
||||
it('model behind the market on an OVER → negative signed edge (amber/red)', () => {
|
||||
const b = computeBreadth(books, 2.0, 'over');
|
||||
expect(b.signedEdge).toBeCloseTo(-0.5);
|
||||
expect(b.position).toBe('below');
|
||||
});
|
||||
|
||||
it('<2 DISTINCT books → null (never fabricate a consensus)', () => {
|
||||
expect(computeBreadth([{ book: 'dk', line: 2.5 }], 3, 'over')).toBeNull();
|
||||
// same book twice is still one opinion → not a consensus
|
||||
expect(computeBreadth([{ book: 'dk', line: 2.5 }, { book: 'dk', line: 1.5 }], 3, 'over')).toBeNull();
|
||||
expect(computeBreadth([], 3)).toBeNull();
|
||||
expect(computeBreadth(null, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('absent model → real consensus present, comparison null (no invented model)', () => {
|
||||
const b = computeBreadth(books, null, 'over');
|
||||
expect(b.consensus).toBe(2.5);
|
||||
expect(b.bookCount).toBe(3);
|
||||
expect(b.model).toBeNull();
|
||||
expect(b.delta).toBeNull();
|
||||
expect(b.signedEdge).toBeNull();
|
||||
expect(b.position).toBeNull();
|
||||
});
|
||||
|
||||
it('a non-numeric book line is IGNORED, not coerced to 0', () => {
|
||||
const mixed = [{ book: 'dk', line: 2.5 }, { book: 'fd', line: null }, { book: 'mgm', line: 2.5 }];
|
||||
const b = computeBreadth(mixed, 2.5, 'over');
|
||||
expect(b.bookCount).toBe(2);
|
||||
expect(b.consensus).toBe(2.5);
|
||||
expect(b.position).toBe('inline'); // model == consensus
|
||||
expect(b.signedEdge).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectBreadth — ranked list, self-hiding', () => {
|
||||
it('drops <2-book props and ranks by |signedEdge| desc', () => {
|
||||
const items = [
|
||||
{ player: 'A', stat: 'hits', side: 'over', line: 1.5, modelValue: 2.0, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }] },
|
||||
{ player: 'B', stat: 'tb', side: 'over', line: 2.5, modelValue: 2.6, books: [{ book: 'dk', line: 2.5 }, { book: 'fd', line: 2.5 }] },
|
||||
{ player: 'C', stat: 'ks', side: 'over', line: 5.5, modelValue: 6, books: [{ book: 'dk', line: 5.5 }] }, // 1 book → dropped
|
||||
];
|
||||
const out = collectBreadth(items, 6);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].player).toBe('A'); // |0.5| edge beats |0.1|
|
||||
expect(out[1].player).toBe('B');
|
||||
});
|
||||
|
||||
it('empty when nothing qualifies (component self-hides)', () => {
|
||||
expect(collectBreadth([{ player: 'C', stat: 'ks', side: 'over', books: [{ book: 'dk', line: 5.5 }] }], 6)).toEqual([]);
|
||||
expect(collectBreadth(null)).toEqual([]);
|
||||
expect(collectBreadth([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('source: MarketBreadth component self-hides + colors by sign', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
|
||||
|
||||
it('MarketBreadth.tsx colors the edge via the color contract (edgeColor), not raw green', () => {
|
||||
const src = read('components/vyndr/MarketBreadth.tsx');
|
||||
expect(src).toContain('edgeColor');
|
||||
});
|
||||
|
||||
it('MarketBreadth.tsx self-hides when there is nothing to show', () => {
|
||||
const src = read('components/vyndr/MarketBreadth.tsx');
|
||||
expect(src).toMatch(/return null/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Wave 4A — OUTLOOK MODE (never-empty slate grid, Step 3).
|
||||
// The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no
|
||||
// live games it falls back to REAL always-available data: yesterday's PROVEN
|
||||
// A-tier receipts + tomorrow's date-pinned schedule preview. A network
|
||||
// fetchError stays an ERROR state (a fetch failure is never a fake outlook).
|
||||
|
||||
const { buildOutlook, mapTomorrowPreview } = require('../../web/src/lib/outlook');
|
||||
|
||||
describe('mapTomorrowPreview — real schedule → preview rows', () => {
|
||||
const games = [
|
||||
{ id: 'g1', awayTeam: { name: 'Yankees' }, homeTeam: { name: 'Red Sox' }, gameTime: '2026-07-14T23:00:00Z', status: 'pre', sport: 'mlb' },
|
||||
{ id: 'g2', awayTeam: { abbreviation: 'LAD' }, homeTeam: { abbreviation: 'SF' }, status: 'in' }, // live → dropped (a preview is upcoming only)
|
||||
{ id: 'g3', awayTeam: {}, homeTeam: { name: 'X' } }, // missing away team → dropped (never fabricate a matchup)
|
||||
{ id: 'g4', awayTeam: { name: 'Cubs' }, homeTeam: { name: 'Cards' }, status: 'post' }, // finished → dropped
|
||||
];
|
||||
|
||||
it('maps upcoming games only, dropping live / finished / incomplete', () => {
|
||||
const out = mapTomorrowPreview(games, 8);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ id: 'g1', away: 'Yankees', home: 'Red Sox', sport: 'MLB' });
|
||||
});
|
||||
|
||||
it('caps at the limit and never fabricates a matchup from bad input', () => {
|
||||
const many = Array.from({ length: 10 }, (_, i) => ({ id: `g${i}`, awayTeam: { name: `A${i}` }, homeTeam: { name: `H${i}` }, status: 'pre' }));
|
||||
expect(mapTomorrowPreview(many, 3)).toHaveLength(3);
|
||||
expect(mapTomorrowPreview(null, 5)).toEqual([]);
|
||||
expect(mapTomorrowPreview(undefined, 5)).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts the flat {away,home} shape too (dashboard schedule mapping)', () => {
|
||||
const out = mapTomorrowPreview([{ id: 'z', away: 'Mets', home: 'Phillies', start_time: '2026-07-14T18:00:00Z' }], 8);
|
||||
expect(out[0]).toMatchObject({ away: 'Mets', home: 'Phillies' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOutlook — never-empty selection', () => {
|
||||
const settled = [
|
||||
{ player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, sport: 'mlb' },
|
||||
{ player_name: 'Low Grade', stat: 'hits', line: 0.5, side: 'over', grade: 'C', outcome: 'hit' }, // not A-tier → not a receipt
|
||||
{ player_name: 'The Miss', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss' }, // a miss never becomes proof
|
||||
];
|
||||
const tomorrow = [{ id: 't1', awayTeam: { name: 'Mets' }, homeTeam: { name: 'Braves' }, status: 'pre', sport: 'mlb' }];
|
||||
|
||||
it('a network fetchError stays an ERROR state — never a fabricated outlook', () => {
|
||||
expect(buildOutlook({ gamesCount: 0, fetchError: 'No games available right now.', settledRows: settled, tomorrow })).toEqual({ mode: 'error' });
|
||||
});
|
||||
|
||||
it('live games on the board → live mode (outlook not shown)', () => {
|
||||
expect(buildOutlook({ gamesCount: 3, settledRows: settled, tomorrow })).toEqual({ mode: 'live' });
|
||||
});
|
||||
|
||||
it('no games → outlook carrying PROVEN receipts AND tomorrow preview (never blank)', () => {
|
||||
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow });
|
||||
expect(o.mode).toBe('outlook');
|
||||
expect(o.receipts).toHaveLength(1);
|
||||
expect(o.receipts[0].player).toBe('Aaron Judge');
|
||||
expect(o.receipts[0].outcome).toBe('hit');
|
||||
expect(o.tomorrow).toHaveLength(1);
|
||||
expect(o.tomorrow[0].home).toBe('Braves');
|
||||
expect(o.hasContent).toBe(true);
|
||||
});
|
||||
|
||||
it('no games, no proof, no schedule → STILL an outlook surface (the header carries it), never error / never blank', () => {
|
||||
const o = buildOutlook({ gamesCount: 0, settledRows: [], tomorrow: [] });
|
||||
expect(o.mode).toBe('outlook');
|
||||
expect(o.receipts).toEqual([]);
|
||||
expect(o.tomorrow).toEqual([]);
|
||||
expect(o.hasContent).toBe(false);
|
||||
});
|
||||
|
||||
it('receipts alone (no tomorrow schedule) still fills the grid', () => {
|
||||
const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow: [] });
|
||||
expect(o.mode).toBe('outlook');
|
||||
expect(o.receipts).toHaveLength(1);
|
||||
expect(o.tomorrow).toEqual([]);
|
||||
expect(o.hasContent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The dead-end CTA cards must be gone: the grid now renders an Outlook surface.
|
||||
describe('source: the slate + dashboard render the Outlook surface (not a NO-SLATE dead-end)', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');
|
||||
|
||||
it('Slate.tsx wires buildOutlook / the Outlook surface into the empty branch', () => {
|
||||
const src = read('components/Slate.tsx');
|
||||
expect(src).toMatch(/OutlookSurface|buildOutlook/);
|
||||
});
|
||||
|
||||
it('dashboard renders the Outlook surface instead of the "NO SLATE" CTA', () => {
|
||||
const src = read('app/dashboard/page.tsx');
|
||||
expect(src).toMatch(/OutlookSurface|buildOutlook|mapTomorrowPreview/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user