Files
stonks-oracle/frontend/src/pages/RecommendationDetail.tsx
T
Celes Renata 7fcc8a6c07 feat: model validation, calibration, and signal quality layer
- Migration 035: prediction_snapshots, prediction_outcomes, signal_evidence_links, model_metric_snapshots tables + SQL views
- Prediction snapshot writer with canonical evidence keys, duplicate detection, contribution scores
- Outcome evaluator across 5 horizons (1h, 6h, 1d, 7d, 30d)
- Metrics engine: ECE, Brier score, IC, Rank IC, benchmark comparison
- Attribution engine: per-source, per-catalyst, per-layer performance
- Calibration engine: Bayesian shrinkage source reliability
- Quality gate for live trading eligibility with configurable thresholds
- 7 new /api/validation/* endpoints
- Upgraded OpsModel dashboard with validation tab
- Enhanced recommendation display with calibration context
- Backtest replay validation mode
- 86 Python tests (unit + property-based), 179 frontend tests passing
2026-05-01 03:04:58 +00:00

316 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Recommendation detail page with validation context.
*
* Shows original confidence alongside calibrated confidence (historical win rate),
* evidence quality indicators, source reliability, and live eligibility status.
*
* Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7
*/
import { useParams, Link } from '@tanstack/react-router';
import { AlertTriangle, ShieldCheck, ShieldX, Info } from 'lucide-react';
import {
useRecommendation,
useValidationCalibration,
useValidationGateStatus,
useValidationAttributionSources,
} from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
export function RecommendationDetailPage() {
const { id } = useParams({ from: '/recommendations/$id' });
const { data: rec, isLoading } = useRecommendation(id);
const { data: calibration } = useValidationCalibration();
const { data: gateData } = useValidationGateStatus();
const { data: sourcesData } = useValidationAttributionSources();
if (isLoading || !rec) return <LoadingSpinner />;
// --- Calibration: find the bucket matching this recommendation's confidence ---
const matchingBucket = calibration?.buckets?.find(
(b) => rec.confidence >= b.bucket_low && rec.confidence < b.bucket_high,
);
// Handle edge case: confidence of exactly 1.0 falls in the last bucket [0.90, 1.00]
const calibratedBucket =
matchingBucket ??
(rec.confidence >= 1.0
? calibration?.buckets?.find((b) => b.bucket_high >= 1.0)
: undefined);
const historicalWinRate = calibratedBucket?.observed_win_rate;
// --- Evidence counts ---
const totalEvidenceCount = rec.evidence.length;
// Compute duplicate evidence: group by normalized title, count extras
const titleCounts = new Map<string, number>();
for (const ev of rec.evidence) {
const key = (ev.title ?? '').toLowerCase().trim();
titleCounts.set(key, (titleCounts.get(key) ?? 0) + 1);
}
let duplicateEvidenceCount = 0;
for (const count of titleCounts.values()) {
if (count > 1) duplicateEvidenceCount += count - 1;
}
const uniqueEvidenceCount = totalEvidenceCount - duplicateEvidenceCount;
const duplicateRatio = totalEvidenceCount > 0 ? duplicateEvidenceCount / totalEvidenceCount : 0;
const hasDuplicateWarning = duplicateRatio > 0.2;
// --- Source reliability: find primary contributing sources ---
const evidenceSources = new Map<string, number>();
for (const ev of rec.evidence) {
const src = ev.source_type ?? ev.publisher ?? 'unknown';
evidenceSources.set(src, (evidenceSources.get(src) ?? 0) + ev.weight);
}
// Sort by total weight descending to find primary source
const sortedSources = [...evidenceSources.entries()].sort((a, b) => b[1] - a[1]);
const primarySourceType = sortedSources[0]?.[0];
// Look up source reliability from attribution data
const primarySourceAttribution = sourcesData?.sources?.find(
(s) => s.source_type === primarySourceType || s.source === primarySourceType,
);
// Source reliability is approximated from win_rate via Bayesian shrinkage
// The attribution data has win_rate which is the observed metric
const primarySourceWinRate = primarySourceAttribution?.win_rate;
// Bayesian shrinkage: reliability = 0.5 + (n/(n+30)) * (win_rate - 0.5)
const primarySourceCount = primarySourceAttribution?.prediction_count ?? 0;
const primarySourceReliability =
primarySourceWinRate != null
? 0.5 + (primarySourceCount / (primarySourceCount + 30)) * (primarySourceWinRate - 0.5)
: undefined;
const hasLowReliabilityWarning =
primarySourceReliability != null && primarySourceReliability < 0.4;
// --- Gate status ---
const gateStatus = gateData?.gate_status as {
passed?: boolean;
reason?: string;
threshold_results?: Array<{ name: string; threshold: number; actual: number; passed: boolean }>;
} | null;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-100">{rec.ticker}</h1>
<StatusBadge status={rec.action} />
<StatusBadge status={rec.mode} />
</div>
<Card>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
<div><dt className="text-gray-500">Confidence</dt><dd><ConfidenceBar value={rec.confidence} /></dd></div>
<div><dt className="text-gray-500">Horizon</dt><dd className="text-gray-200">{rec.time_horizon}</dd></div>
<div><dt className="text-gray-500">Risk</dt><dd><StatusBadge status={rec.risk_classification} /></dd></div>
<div><dt className="text-gray-500">Generated</dt><dd className="text-gray-300">{new Date(rec.generated_at).toLocaleString()}</dd></div>
<div><dt className="text-gray-500">Portfolio %</dt><dd className="text-gray-200">{rec.portfolio_pct != null ? `${(rec.portfolio_pct * 100).toFixed(1)}%` : '—'}</dd></div>
<div><dt className="text-gray-500">Max Loss %</dt><dd className="text-gray-200">{rec.max_loss_pct != null ? `${(rec.max_loss_pct * 100).toFixed(2)}%` : '—'}</dd></div>
<div><dt className="text-gray-500">Model</dt><dd className="text-xs text-gray-400">{rec.model_version ?? '—'}</dd></div>
</dl>
</Card>
{/* Validation Context Card — Requirements 13.113.7 */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Validation Context</h2>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-3">
{/* 13.1: Original confidence alongside calibrated confidence */}
<div>
<dt className="text-gray-500">Original Confidence</dt>
<dd className="text-gray-200">{(rec.confidence * 100).toFixed(1)}%</dd>
</div>
<div>
<dt className="text-gray-500">Calibrated Confidence</dt>
<dd className="text-gray-200">
{historicalWinRate != null
? `${(historicalWinRate * 100).toFixed(1)}%`
: 'N/A'}
</dd>
</div>
{/* 13.2: Historical win rate for similar confidence levels */}
<div>
<dt className="text-gray-500">Historical Win Rate</dt>
<dd className="text-gray-200">
{historicalWinRate != null ? (
<span>
{(historicalWinRate * 100).toFixed(1)}%
{calibratedBucket && (
<span className="ml-1 text-xs text-gray-500">
({calibratedBucket.prediction_count} predictions)
</span>
)}
</span>
) : (
'N/A'
)}
</dd>
</div>
{/* 13.3: Evidence count, unique evidence count, duplicate evidence count */}
<div>
<dt className="text-gray-500">Evidence Count</dt>
<dd className="text-gray-200">{totalEvidenceCount}</dd>
</div>
<div>
<dt className="text-gray-500">Unique Evidence</dt>
<dd className="text-gray-200">{uniqueEvidenceCount}</dd>
</div>
<div>
<dt className="flex items-center gap-1 text-gray-500">
Duplicate Evidence
{/* 13.6: Warning badge when duplicate evidence count > 20% of total */}
{hasDuplicateWarning && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-yellow-700/50 bg-yellow-900/40 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400"
title="Duplicate evidence exceeds 20% of total — potential evidence inflation"
>
<AlertTriangle size={10} />
&gt;20%
</span>
)}
</dt>
<dd className="text-gray-200">
{duplicateEvidenceCount}
{totalEvidenceCount > 0 && (
<span className="ml-1 text-xs text-gray-500">
({(duplicateRatio * 100).toFixed(0)}%)
</span>
)}
</dd>
</div>
{/* 13.4: Source reliability indicator */}
<div>
<dt className="flex items-center gap-1 text-gray-500">
Primary Source Reliability
{/* 13.7: Warning badge when primary source reliability < 0.4 */}
{hasLowReliabilityWarning && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-red-700/50 bg-red-900/40 px-1.5 py-0.5 text-[10px] font-medium text-red-400"
title="Primary source reliability is below 0.4 — low or unknown reliability"
>
<AlertTriangle size={10} />
Low
</span>
)}
</dt>
<dd className="text-gray-200">
{primarySourceReliability != null ? (
<span>
{primarySourceReliability.toFixed(3)}
{primarySourceType && (
<span className="ml-1 text-xs text-gray-500">({primarySourceType})</span>
)}
</span>
) : (
'N/A'
)}
</dd>
</div>
{/* 13.5: Live eligibility status with reason */}
<div className="col-span-2">
<dt className="text-gray-500">Live Eligibility</dt>
<dd>
{gateStatus != null ? (
<div className="flex items-center gap-2">
{gateStatus.passed ? (
<span className="inline-flex items-center gap-1 text-green-400">
<ShieldCheck size={14} />
Gate Passed
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-400">
<ShieldX size={14} />
Gate Failed
</span>
)}
{gateStatus.reason && (
<span className="text-xs text-gray-500">{gateStatus.reason}</span>
)}
</div>
) : (
<span className="inline-flex items-center gap-1 text-gray-500">
<Info size={14} />
N/A no gate evaluation available
</span>
)}
</dd>
</div>
</dl>
</Card>
{rec.thesis && (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Thesis</h2>
<p className="text-sm text-gray-200">{rec.thesis}</p>
</Card>
)}
{rec.invalidation_conditions && rec.invalidation_conditions.length > 0 && (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Invalidation Conditions</h2>
<ul className="ml-4 list-disc text-sm text-yellow-400">
{rec.invalidation_conditions.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</Card>
)}
{/* Risk Evaluation */}
{rec.risk_evaluation && (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Risk Evaluation</h2>
<div className="flex items-center gap-4 text-sm">
<StatusBadge status={rec.risk_evaluation.eligible ? 'approved' : 'rejected'} />
<span className="text-gray-400">Allowed mode: {rec.risk_evaluation.allowed_mode}</span>
</div>
{rec.risk_evaluation.rejection_reasons && rec.risk_evaluation.rejection_reasons.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-sm text-red-400">
{rec.risk_evaluation.rejection_reasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</Card>
)}
{/* Evidence */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Evidence ({rec.evidence.length})</h2>
{rec.evidence.length === 0 ? (
<p className="text-sm text-gray-500">No evidence linked</p>
) : (
<div className="space-y-2">
{rec.evidence.map((ev) => {
const isMacro = ev.document_type === 'macro_event' || ev.evidence_type === 'macro_event';
return (
<div key={ev.id} className={`rounded-lg border p-3 ${isMacro ? 'border-purple-700/50 bg-purple-900/10' : 'border-surface-700 bg-surface-950'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMacro && (
<Link
to="/macro/events/$id"
params={{ id: ev.document_id }}
className="rounded bg-purple-900/40 border border-purple-700/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-400 hover:text-purple-300"
onClick={(e) => e.stopPropagation()}
>
MACRO
</Link>
)}
<StatusBadge status={ev.evidence_type} />
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
</div>
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
</div>
<div className="mt-1 flex gap-4 text-xs text-gray-500">
<span>{ev.document_type}</span>
<span>{ev.source_type}</span>
{ev.publisher && <span>{ev.publisher}</span>}
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
</div>
</div>
);
})}
</div>
)}
</Card>
</div>
);
}