- 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
316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
/**
|
||
* 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.1–13.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} />
|
||
>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>
|
||
);
|
||
}
|