d242b11b4b
PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded. BUILT (was vapor): alt line ladder + edge ranking (same-features regrade at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile P(win) x real captured odds — either missing → no sizing), free-tier kill-condition locked previews. FIXED (was false): analyst 15/day cap vs the Founder 'Unlimited reads' promise → analyst unlimited; every '40+ factors' claim (real count: 22 named features) reworded truthfully in 7 files. VERIFIED: cascade alerts (real, wired), phi correlation, leg history, cross-book comparison, WC soccer, real-time feed. Locked by tests/unit/promiseAudit.test.js. Jest now ignores .claude/worktrees (parallel agents' suites no longer leak into runs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
470 lines
20 KiB
JavaScript
470 lines
20 KiB
JavaScript
/**
|
||
* analyzeViaEngine1 — the canonical single-prop analysis function.
|
||
*
|
||
* Composes the three pieces sessions 6c, 7e, and 7f built:
|
||
* computeFeaturesForProp → engine1.gradeProp → toLegacyShape
|
||
*
|
||
* Output matches the legacy `analyzeProp()` shape byte-for-byte (DemoScan
|
||
* + GradeCard read the same fields). The `reasoning.summary` here is built
|
||
* from real feature values (l5_avg, opp_rank_stat, etc.) so users still
|
||
* see concrete sentences, not abstract factor labels.
|
||
*
|
||
* Never throws. Every upstream failure mode is reflected as low-confidence
|
||
* grade + an explanatory reasoning summary.
|
||
*/
|
||
|
||
const { computeFeaturesForProp } = require('./computeFeatures');
|
||
const engine1 = require('./engine1');
|
||
const { toLegacyShape } = require('../../utils/gradeAdapter');
|
||
|
||
// Map an error code from computeFeaturesForProp.meta.errors into a human
|
||
// sentence the user will see in reasoning.summary.
|
||
const ERROR_EXPLANATIONS = Object.freeze({
|
||
player_not_found_in_id_map: "We couldn't find this player in our roster index.",
|
||
no_game_scheduled_today: "No game scheduled for this player tonight.",
|
||
no_features_computed: "Statistical features unavailable for this player tonight.",
|
||
});
|
||
|
||
function explainErrors(errors) {
|
||
if (!Array.isArray(errors) || errors.length === 0) return '';
|
||
return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' ');
|
||
}
|
||
|
||
// Soccer reasoning — different signals than NBA (xG, penalty role,
|
||
// altitude, referee, minutes). Concrete sentences from real values;
|
||
// nothing fires unless the underlying feature is non-null.
|
||
function buildSoccerReasoningLines(features = {}, meta = {}, prop = {}) {
|
||
const lines = [];
|
||
const statType = prop.stat_type || '';
|
||
|
||
if (Number.isFinite(features.goals_per_90)) {
|
||
lines.push(`${prop.player || 'Player'} scores ${features.goals_per_90.toFixed(2)} goals per 90 minutes.`);
|
||
} else if (Number.isFinite(features.l5_avg)) {
|
||
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(2)} ${statType} over his last 5 matches.`);
|
||
}
|
||
|
||
if (Number.isFinite(features.xg_per_90)) {
|
||
const delta = features.xg_delta;
|
||
let trend = 'tracking expectations';
|
||
if (Number.isFinite(delta)) {
|
||
if (delta > 0.2) trend = 'overperforming — regression risk';
|
||
else if (delta < -0.2) trend = 'underperforming — breakout candidate';
|
||
}
|
||
lines.push(`Expected goals (xG): ${features.xg_per_90.toFixed(2)} per 90 — ${trend}.`);
|
||
}
|
||
|
||
if (features.is_penalty_taker) {
|
||
lines.push('Designated penalty taker — adds ~0.15 goals per 90 to base rate.');
|
||
}
|
||
if (features.takes_free_kicks && (statType === 'goals' || statType === 'shots' || statType === 'shots_on_target')) {
|
||
lines.push('Direct free-kick specialist — boosts shot/goal probability on fouls drawn.');
|
||
}
|
||
if (features.takes_corners && statType === 'assists') {
|
||
lines.push('Designated corner taker — meaningfully lifts assist probability.');
|
||
}
|
||
|
||
if (features.altitude_impact === 'high') {
|
||
lines.push(`Match at ${features.venue_altitude_ft || 'high'}ft altitude. ${features.home_continent ? 'Acclimated host team.' : 'Non-acclimatized side — historical goal reduction.'}`);
|
||
} else if (features.altitude_impact === 'moderate' && !features.home_continent) {
|
||
lines.push(`Moderate altitude at ${features.venue_altitude_ft || 'venue'}ft — minor stamina impact.`);
|
||
}
|
||
|
||
if (Number.isFinite(features.referee_cards_per_game)) {
|
||
const refName = features.referee_name || 'Referee';
|
||
lines.push(`${refName} averages ${features.referee_cards_per_game.toFixed(1)} cards per match.`);
|
||
}
|
||
|
||
if (Number.isFinite(features.minutes_per_game) && features.minutes_per_game < 75) {
|
||
lines.push(`Averaging only ${features.minutes_per_game.toFixed(0)} minutes per match — line may assume full 90.`);
|
||
}
|
||
|
||
if (Number.isFinite(features.opp_goals_conceded_per_game)) {
|
||
lines.push(`${meta.opponentAbbr || 'Opponent'} concedes ${features.opp_goals_conceded_per_game.toFixed(2)} goals per game.`);
|
||
}
|
||
|
||
if (features.tournament_player && Number.isFinite(features.wc_goals_career)) {
|
||
lines.push(`Tournament pedigree: ${features.wc_goals_career} career World Cup goals.`);
|
||
}
|
||
|
||
if (features.home_away === 1.0) lines.push('Playing at home.');
|
||
else if (features.home_away === 0.0) lines.push('Playing on the road.');
|
||
|
||
return lines;
|
||
}
|
||
|
||
// Build a human-readable reasoning summary + steps from the actual
|
||
// features (which carry real numbers) and engine1's grade.
|
||
function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) {
|
||
// Soccer (Session 7j) routes to a sport-specific line builder and
|
||
// returns before the NBA-flavored sentences would fire. The closer
|
||
// logic (trap, engine1 verdict, error gaps, steps shape) is shared
|
||
// between sports and lives below this branch.
|
||
const sportLc = String(meta.sport || '').toLowerCase();
|
||
const isSoccer = sportLc === 'soccer' || sportLc === 'football';
|
||
|
||
const lines = isSoccer
|
||
? buildSoccerReasoningLines(features, meta, prop)
|
||
: [];
|
||
|
||
if (!isSoccer) {
|
||
// Recent form vs the line — L5 and L20 are the orchestrator's
|
||
// canonical season-trend signals.
|
||
if (Number.isFinite(features.l5_avg)) {
|
||
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`);
|
||
}
|
||
if (Number.isFinite(features.l20_avg)) {
|
||
lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`);
|
||
}
|
||
|
||
// Trend direction relative to the line.
|
||
if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) {
|
||
const diff = features.l5_avg - prop.line;
|
||
if (Math.abs(diff) >= 0.5) {
|
||
const dir = diff > 0 ? 'above' : 'below';
|
||
lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`);
|
||
}
|
||
}
|
||
|
||
// Home / away.
|
||
if (features.home_away === 1.0) lines.push('Playing at home tonight.');
|
||
else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
|
||
}
|
||
|
||
if (!isSoccer) {
|
||
// Opponent matchup. opp_rank_stat is 0..1 normalized
|
||
// (0 = best D, 1 = worst D) — translate to friendlier language.
|
||
if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
|
||
if (features.opp_rank_stat >= 0.7) {
|
||
lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`);
|
||
} else if (features.opp_rank_stat <= 0.3) {
|
||
lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`);
|
||
} else {
|
||
lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`);
|
||
}
|
||
}
|
||
|
||
// Rest / fatigue context.
|
||
if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.');
|
||
else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) {
|
||
lines.push(`${features.rest_days} days of rest.`);
|
||
}
|
||
if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) {
|
||
lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`);
|
||
}
|
||
|
||
// Injury context.
|
||
if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) {
|
||
lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
|
||
}
|
||
}
|
||
|
||
// Trap composite — surfaced when meaningful.
|
||
// (Adapter handles the per-factor kill_conditions chips; this line
|
||
// gives the user the overall warning.)
|
||
if (engine1Result?.grade && engine1Result.grade.endsWith('-') === false
|
||
&& Array.isArray(engine1Result.all_factors)
|
||
&& engine1Result.all_factors.includes('trap_composite_high')) {
|
||
lines.push('Multiple trap signals firing — proceed with caution.');
|
||
}
|
||
|
||
// Engine-1 verdict capper.
|
||
const grade = engine1Result?.grade;
|
||
if (grade) {
|
||
const verb = grade.startsWith('A') ? 'favors the play'
|
||
: grade.startsWith('B') ? 'leans toward the play'
|
||
: grade.startsWith('C') ? 'is split'
|
||
: 'leans against the play';
|
||
lines.push(`Engine 1 graded ${grade} — ${verb}.`);
|
||
}
|
||
|
||
// Tack on any data-gap explanations.
|
||
const gapNote = explainErrors(meta.errors);
|
||
if (gapNote) lines.push(gapNote);
|
||
|
||
const summary = lines.join(' ').trim()
|
||
|| `Analysis complete. Grade: ${grade || 'C'}.`;
|
||
|
||
// Legacy-shaped steps so backward-compat callers (integration tests,
|
||
// anything pre-dating the engine swap) keep seeing the named
|
||
// sub-blocks. Each is populated from engine1 features where the data
|
||
// exists; missing sub-blocks contain null fields instead of being
|
||
// absent so callers can dot-access without optional chaining.
|
||
const seasonAvg = Number.isFinite(features.l20_avg) ? features.l20_avg : null;
|
||
const recentAvg = Number.isFinite(features.l5_avg) ? features.l5_avg : null;
|
||
const haContext = features.home_away === 1.0 ? 'home'
|
||
: features.home_away === 0.0 ? 'away' : null;
|
||
const restContext = features.rest_days === 0 ? 'b2b'
|
||
: Number.isFinite(features.rest_days) && features.rest_days >= 2 ? 'rested' : null;
|
||
|
||
return {
|
||
summary,
|
||
steps: {
|
||
season_avg: {
|
||
value: seasonAvg,
|
||
vs_line: seasonAvg != null && Number.isFinite(prop.line)
|
||
? Math.round((seasonAvg - prop.line) * 10) / 10 : null,
|
||
signal: null,
|
||
},
|
||
recent_form: {
|
||
value: recentAvg,
|
||
vs_line: recentAvg != null && Number.isFinite(prop.line)
|
||
? Math.round((recentAvg - prop.line) * 10) / 10 : null,
|
||
signal: null,
|
||
},
|
||
situational: {
|
||
home_away: { value: null, context: haContext, signal: null },
|
||
rest_days: { value: features.rest_days ?? null, context: restContext, signal: null },
|
||
vs_opponent: { value: null, games: null, signal: null },
|
||
},
|
||
line_comparison: {
|
||
best_line: null,
|
||
worst_line: null,
|
||
edge_from_best: 0,
|
||
signal: null,
|
||
},
|
||
kill_conditions: [],
|
||
final_grade: grade || null,
|
||
// Flat narrative bullets — kept under `steps` so legacy clients
|
||
// can still find them but they don't collide with the named
|
||
// sub-blocks above.
|
||
narrative: lines.map((line, i) => ({ step: i + 1, detail: line })),
|
||
},
|
||
};
|
||
}
|
||
|
||
// The model's projection: the same reference the edge is computed from.
|
||
// Recent-form averages first (l5, else l20); soccer props (which carry
|
||
// per-90 rates instead of game averages) fall back to `{stat}_per_90`,
|
||
// then xG for goals. This is a REAL model number — it is never the line.
|
||
// When it's null the model has no projection and the read must refuse
|
||
// (insufficient_data), not ship a hollow grade.
|
||
function projectionFor(features, prop) {
|
||
const f = features || {};
|
||
const round2 = (n) => Math.round(n * 100) / 100;
|
||
if (Number.isFinite(f.l5_avg)) return round2(f.l5_avg);
|
||
if (Number.isFinite(f.l20_avg)) return round2(f.l20_avg);
|
||
const stat = String(prop?.stat_type || '').toLowerCase();
|
||
const per90 = f[`${stat}_per_90`];
|
||
if (Number.isFinite(per90)) return round2(per90);
|
||
if (stat === 'goals' && Number.isFinite(f.xg_per_90)) return round2(f.xg_per_90);
|
||
return null;
|
||
}
|
||
|
||
// edge_pct in the legacy shape compares the model projection to the line.
|
||
function edgePctFor(features, prop) {
|
||
const ref = projectionFor(features, prop);
|
||
if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
|
||
const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref);
|
||
return Math.round((signed / prop.line) * 1000) / 10;
|
||
}
|
||
|
||
// Session 58 (work-order 1.5) — when the model has NO projection there is no
|
||
// read. This used to return grade 'C' / confidence 10 / edge 0 — the exact
|
||
// "hollow C" the live audit caught (model==line, +0% edge). A refused read
|
||
// builds more trust than a fake one: grade is null, insufficient_data is
|
||
// true, and NOTHING downstream (grades cache, snapshot, ledger) persists it.
|
||
function insufficientDataResult(rawProp, errors) {
|
||
return {
|
||
player: rawProp.player ?? null,
|
||
stat_type: rawProp.stat_type ?? null,
|
||
line: rawProp.line ?? null,
|
||
direction: rawProp.direction ?? null,
|
||
book: rawProp.book || 'unknown',
|
||
grade: null,
|
||
insufficient_data: true,
|
||
confidence: 0,
|
||
edge_pct: 0,
|
||
projection: null,
|
||
kill_conditions_triggered: [],
|
||
reasoning: {
|
||
summary: `INSUFFICIENT DATA — no read. ${explainErrors(errors) || 'The model has no projection for this prop.'}`.trim(),
|
||
steps: [],
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Form score (0..100) from recent-vs-baseline averages (Session 43). Hot
|
||
* (l5 > l20) trends above 70; cold below. undefined when there's no recent avg.
|
||
*/
|
||
function computeFormScore(features = {}) {
|
||
const l5 = features.l5_avg;
|
||
if (!Number.isFinite(l5)) return undefined;
|
||
const base = Number.isFinite(features.l20_avg) ? features.l20_avg
|
||
: Number.isFinite(features.l10_avg) ? features.l10_avg : null;
|
||
if (base == null || base === 0) return 75;
|
||
const ratio = l5 / base;
|
||
return Math.round(Math.max(40, Math.min(99, 70 + (ratio - 1) * 60)));
|
||
}
|
||
|
||
function matchupGradeFromRank(rank) {
|
||
if (!Number.isFinite(rank)) return undefined;
|
||
if (rank >= 0.66) return 'A';
|
||
if (rank >= 0.5) return 'B+';
|
||
if (rank >= 0.33) return 'B';
|
||
return 'C';
|
||
}
|
||
|
||
/**
|
||
* Intelligence fields for the grade card (Session 43) — STAT CONTEXT + VYNDR
|
||
* INTELLIGENCE. Computed ONLY from the already-built feature vector (no extra
|
||
* I/O), so every field is optional and self-hides on the card when absent.
|
||
* NOTE: archetype is intentionally NOT set here — the per-prop feature vector
|
||
* doesn't carry a full multi-stat season line, so classifying it would just
|
||
* yield the fallback. The archetype strip lights up once the snapshot pipeline
|
||
* (Session 44) feeds per-player season lines into the grade response.
|
||
*/
|
||
const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v));
|
||
|
||
function buildIntelFields(features = {}, opts = {}) {
|
||
const out = {};
|
||
const round1 = (n) => Math.round(n * 10) / 10;
|
||
// Resilience (Session 46): fall back to a caller-supplied playerStats bundle
|
||
// and the model projection when the feature vector is sparse. Partial intel
|
||
// beats none — we add only the fields we can actually back with a number.
|
||
const ps = opts.playerStats || {};
|
||
const proj = Number.isFinite(opts.projection) ? opts.projection : undefined;
|
||
|
||
const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj);
|
||
if (seasonAvg != null) out.season_avg = round1(seasonAvg);
|
||
|
||
const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg);
|
||
if (last10 != null) out.last10_avg = round1(last10);
|
||
|
||
let form = computeFormScore(features);
|
||
if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form);
|
||
if (form != null) out.form = form;
|
||
|
||
if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
|
||
else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
|
||
else if (Number.isFinite(features.ab_per_game)) out.usage = `${round1(features.ab_per_game)} AB/G`; // MLB usage equivalent
|
||
else if (ps.usage) out.usage = String(ps.usage);
|
||
|
||
const matchup = matchupGradeFromRank(features.opp_rank_stat)
|
||
|| (Number.isFinite(features.bvp_advantage) ? (features.bvp_advantage > 0.05 ? 'A' : features.bvp_advantage > 0 ? 'B+' : 'C') : null);
|
||
if (matchup) out.matchup_grade = matchup;
|
||
|
||
if (Number.isFinite(features.rest_days)) out.rest = features.rest_days === 0 ? 'B2B' : `${features.rest_days}d rest`;
|
||
return out;
|
||
}
|
||
|
||
async function analyzeViaEngine1(rawProp = {}) {
|
||
const featureResult = await computeFeaturesForProp(rawProp);
|
||
const { features, trap, consistency, prop, meta } = featureResult;
|
||
|
||
// Hard refusal when computeFeatures couldn't produce anything useful at
|
||
// all (no features AND no consistency input) — there is nothing to grade.
|
||
if ((!features || Object.keys(features).length === 0)
|
||
&& (!consistency || consistency.consistency === 'unknown')
|
||
&& (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) {
|
||
return insufficientDataResult(rawProp, meta?.errors);
|
||
}
|
||
|
||
// Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model
|
||
// reference the edge is fictional and the grade would be hollow.
|
||
const projection = projectionFor(features, { ...rawProp, line: prop.line });
|
||
if (projection == null) {
|
||
return insufficientDataResult(rawProp, meta?.errors);
|
||
}
|
||
|
||
// Engine 1: deterministic rule-based grade on the feature vector.
|
||
const engine1Result = engine1.gradeProp({ features, trap, consistency, prop });
|
||
|
||
// Translate engine1 output → legacy shape via the adapter from 7e.
|
||
// The adapter handles kill_conditions_triggered + the 4-letter grade
|
||
// collapse + the 0-100 confidence scale.
|
||
const summaryOverride = buildConcreteReasoning(features, engine1Result, meta, {
|
||
...rawProp,
|
||
line: prop.line,
|
||
}).summary;
|
||
|
||
const legacy = toLegacyShape(engine1Result, {
|
||
player: rawProp.player,
|
||
stat_type: rawProp.stat_type,
|
||
line: prop.line,
|
||
direction: prop.direction,
|
||
book: rawProp.book || 'unknown',
|
||
sport: meta.sport,
|
||
}, {
|
||
summaryOverride,
|
||
edgePct: edgePctFor(features, prop),
|
||
});
|
||
|
||
// The adapter's reasoning.steps was a single-element debug bag;
|
||
// replace it with the line-by-line breakdown we built above so the
|
||
// legacy UI's step list looks identical to before.
|
||
legacy.reasoning = buildConcreteReasoning(features, engine1Result, meta, {
|
||
...rawProp,
|
||
line: prop.line,
|
||
});
|
||
|
||
// Session 43 — attach grade-card intelligence fields (stat context + VYNDR
|
||
// intelligence). Optional + self-hiding on the card; zero extra I/O.
|
||
Object.assign(legacy, buildIntelFields(features));
|
||
|
||
// Session 58 — the model's REAL projection (never the line). Persisted as
|
||
// ledger model_value and rendered under the MODEL label on the card.
|
||
legacy.projection = projection;
|
||
|
||
// Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page
|
||
// sold it; the adapter/card were already built for it; nothing produced
|
||
// it. Re-grade the SAME feature vector at shifted lines (zero extra I/O)
|
||
// and rank by edge. Attached for every graded result; the API tier gate
|
||
// strips it below Desk.
|
||
try {
|
||
const baseLine = Number(prop.line);
|
||
if (Number.isFinite(baseLine)) {
|
||
const ladder = [-1, -0.5, 0, 0.5, 1]
|
||
.map((shift) => Math.round((baseLine + shift) * 100) / 100)
|
||
.filter((ln) => ln > 0)
|
||
.map((ln) => {
|
||
const shiftedProp = { ...prop, line: ln };
|
||
const g = engine1.gradeProp({ features, trap, consistency, prop: shiftedProp });
|
||
const adapted = toLegacyShape(g, {
|
||
player: rawProp.player, stat_type: rawProp.stat_type,
|
||
line: ln, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport,
|
||
}, { edgePct: edgePctFor(features, { ...shiftedProp, stat_type: rawProp.stat_type, direction: prop.direction }) });
|
||
return { line: ln, grade: adapted.grade, edge_pct: adapted.edge_pct, base: ln === baseLine };
|
||
})
|
||
.sort((a, b) => (Number(b.edge_pct) || 0) - (Number(a.edge_pct) || 0));
|
||
if (ladder.length > 1) legacy.alt_lines = ladder;
|
||
}
|
||
} catch { /* the ladder is additive — never breaks the read */ }
|
||
|
||
// Session 62 (A1-S1) — QUARTER-KELLY. Real probability (quantile estimator
|
||
// over the actual game logs) × real book odds, or nothing. Never derived
|
||
// from confidence, never a default vig.
|
||
try {
|
||
const { estimateProbability } = require('./probabilityEstimator');
|
||
const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features });
|
||
const pWin = String(prop.direction || 'over').toLowerCase() === 'under'
|
||
? (Number.isFinite(est.p_over) ? 1 - est.p_over : null)
|
||
: (Number.isFinite(est.p_over) ? est.p_over : null);
|
||
if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;
|
||
const sideOdds = String(prop.direction || 'over').toLowerCase() === 'under'
|
||
? rawProp.under_odds : rawProp.over_odds;
|
||
if (pWin != null && sideOdds != null) {
|
||
const { quarterKelly } = require('../../utils/kelly');
|
||
const k = quarterKelly(pWin, sideOdds);
|
||
if (k) legacy.kelly = { ...k, odds: String(sideOdds) };
|
||
}
|
||
} catch { /* sizing is additive — absent beats wrong */ }
|
||
|
||
return legacy;
|
||
}
|
||
|
||
module.exports = {
|
||
analyzeViaEngine1,
|
||
__internals: {
|
||
buildConcreteReasoning,
|
||
edgePctFor,
|
||
projectionFor,
|
||
insufficientDataResult,
|
||
explainErrors,
|
||
ERROR_EXPLANATIONS,
|
||
buildIntelFields,
|
||
computeFormScore,
|
||
matchupGradeFromRank,
|
||
},
|
||
};
|