FairValue
feat: upgrade NLP engine with deep search, fallbacks, and neutral signal detection
778ab74
import { useState, useRef } from 'react'
import {
BarChart, Bar, Cell, XAxis, YAxis, Tooltip, LabelList,
ResponsiveContainer, ReferenceLine,
} from 'recharts'
import { motion } from 'framer-motion'
import { Download } from 'lucide-react'
import { useUsageLimiter } from '../hooks/useUsageLimiter'
import AccessModal from '../components/AccessModal'
import { ReportTemplate } from '../components/ReportTemplate'
import DefinitionOfTerms from '../components/DefinitionOfTerms'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
// ── Types ─────────────────────────────────────────────────────────────────────
interface Ledger {
intrinsic_performance_value: number
category: string
depreciation: number
baseline_value: number
external_multiplier: number
hard_cap: number
}
interface EvalResult {
ledger: Ledger
nlp_results: { durability: number; recency: number; agent: number }
nlp_cached: boolean
nlp_found?: boolean
logs: string[]
shap_data: { feature: string; impact: number }[]
}
// ── Gauge (SVG speedometer) ────────────────────────────────────────────────────
function Gauge({ asking, hardCap }: { asking: number; hardCap: number }) {
const maxVal = Math.max(asking * 1.25, hardCap * 1.25, 50)
const cx = 150, cy = 138, r = 108
const toRad = (deg: number) => (deg * Math.PI) / 180
const pt = (deg: number) => ({
x: cx + r * Math.cos(toRad(deg)),
y: cy + r * Math.sin(toRad(deg)),
})
const frac = (v: number) => Math.min(v / maxVal, 1)
const capDeg = -180 + frac(hardCap) * 180
const askDeg = -180 + frac(asking) * 180
const isOver = asking > hardCap
const arc = (a1: number, a2: number) => {
const s = pt(a1), e = pt(a2)
const large = a2 - a1 > 180 ? 1 : 0
return `M ${s.x.toFixed(1)} ${s.y.toFixed(1)} A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(1)} ${e.y.toFixed(1)}`
}
return (
<svg viewBox="0 0 300 165" style={{ width:'100%', maxWidth:320 }}>
{/* Track */}
<path d={arc(-180,0)} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={18} strokeLinecap="round"/>
{/* Green zone: 0 → cap */}
<path d={arc(-180, capDeg)} fill="none" stroke="var(--profit-color)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
{/* Red zone: cap → asking (only if over) */}
{isOver && (
<path d={arc(capDeg, Math.min(askDeg, 0))} fill="none" stroke="var(--loss-color)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
)}
{/* Needle */}
<line x1={cx} y1={cy} x2={pt(askDeg).x} y2={pt(askDeg).y} stroke="white" strokeWidth={2.5} strokeLinecap="round"/>
<circle cx={cx} cy={cy} r={5} fill="white"/>
{/* Fair Value label */}
<text x={pt(capDeg).x + (capDeg < -90 ? -6 : 6)} y={pt(capDeg).y - 6}
textAnchor={capDeg < -90 ? 'end' : 'start'}
fill="var(--profit-color)" fontSize={9} fontWeight={600}>
Fair Value £{hardCap.toFixed(0)}m
</text>
{/* Center: asking */}
<text x={cx} y={cy + 22} textAnchor="middle" fill="white" fontSize={22} fontWeight={800}>
£{asking.toFixed(0)}m
</text>
<text x={cx} y={cy + 40} textAnchor="middle"
fill={isOver ? 'var(--loss-color)' : 'var(--profit-color)'} fontSize={12} fontWeight={600}>
{isOver
? `▲ £${(asking - hardCap).toFixed(1)}m OVER FAIR VALUE`
: `✓ Within Fair Value`}
</text>
{/* Scale ticks */}
{[0, 0.25, 0.5, 0.75, 1].map(f => {
const deg = -180 + f * 180
const inner = { x: cx + (r-12) * Math.cos(toRad(deg)), y: cy + (r-12) * Math.sin(toRad(deg)) }
const outer = { x: cx + (r+2) * Math.cos(toRad(deg)), y: cy + (r+2) * Math.sin(toRad(deg)) }
return <line key={f} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} stroke="rgba(255,255,255,0.15)" strokeWidth={1}/>
})}
</svg>
)
}
// ── SHAP Chart ────────────────────────────────────────────────────────────────
function ShapChart({ data }: { data: { feature: string; impact: number }[] }) {
return (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} layout="vertical" margin={{ left: 0, right: 24, top: 4, bottom: 4 }}>
<XAxis type="number" tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
<YAxis type="category" dataKey="feature" tick={{ fill:'var(--text-2)', fontSize:11 }} width={140} axisLine={false} tickLine={false}/>
<Tooltip
cursor={{ fill:'rgba(255,255,255,0.04)' }}
contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border-hi)', borderRadius:8, fontSize:12, color:'var(--text-1)' }}
itemStyle={{ color: 'var(--text-1)' }}
labelStyle={{ color: 'var(--text-2)', marginBottom: 4 }}
formatter={(v: number) => [v > 0 ? `+${v.toFixed(3)}` : v.toFixed(3), 'Market Impact']}
/>
<ReferenceLine x={0} stroke="rgba(255,255,255,0.12)" strokeWidth={1}/>
<Bar dataKey="impact" radius={3}>
<LabelList
dataKey="impact"
position="right"
fill="var(--text-1)"
fontSize={11}
fontWeight={600}
formatter={(v: number) => v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2)}
/>
{data.map((entry, i) => (
<Cell key={i} fill={entry.impact >= 0 ? 'var(--profit-color)' : 'var(--loss-color)'} fillOpacity={0.85}/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}
// ── NLP Sentiment Score ───────────────────────────────────────────────────────
function SentimentScore({ label, value, icon, found }: { label: string; value: number; icon: string, found?: boolean }) {
const pct = Math.round(((value + 1) / 2) * 100)
const isNeutral = value === 0
const color = value > 0.1 ? 'var(--profit-color)' : value < -0.1 ? 'var(--loss-color)' : (isNeutral && found ? 'var(--text-3)' : 'var(--accent-blue)')
return (
<div>
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
<span style={{ fontSize:'0.8rem', color:'var(--text-2)', display:'flex', gap:6, alignItems:'center' }}>
{icon} {label}
</span>
<span style={{ fontSize:'0.8rem', fontWeight:700, color }}>
{isNeutral ? (found ? 'Neutral' : 'No Data') : (value > 0 ? '+' : '') + value.toFixed(2)}
</span>
</div>
<div className="sentiment-bar-track">
<div className="sentiment-bar-fill" style={{ width:`${pct}%`, background:color }}/>
</div>
</div>
)
}
// ── Custom Loaders ────────────────────────────────────────────────────────────
function AILoader() {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20, margin: '40px 0' }}>
<motion.div
animate={{
rotate: 360,
scale: [1, 1.2, 1],
y: [0, -40, 0]
}}
transition={{
rotate: { duration: 1.5, repeat: Infinity, ease: "linear" },
y: { duration: 0.5, repeat: Infinity, ease: "easeOut", repeatType: "mirror" },
scale: { duration: 0.5, repeat: Infinity, ease: "easeInOut" }
}}
style={{ fontSize: '64px', lineHeight: 1, filter: 'drop-shadow(0 20px 15px rgba(0,0,0,0.6))' }}
>
</motion.div>
<div style={{ textAlign: 'center' }}>
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
style={{ fontSize: '0.9rem', color: '#60a5fa', fontWeight: 800, letterSpacing: '0.2em' }}
>
SCOUTING DATA CHANNELS...
</motion.div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-3)', marginTop: 4 }}>
Accessing Premier League Performance Archives
</div>
</div>
</div>
)
}
function ButtonPulse() {
return (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
style={{
fontSize: '14px',
lineHeight: 1,
marginRight: 6,
display: 'inline-block'
}}
>
</motion.div>
)
}
// ── Main Estimator Page ───────────────────────────────────────────────────────
export default function Estimator() {
const { isLocked, incrementUsage } = useUsageLimiter()
const reportRef = useRef<HTMLDivElement>(null)
const [form, setForm] = useState({
selected_name: '',
current_club: '',
interested_club: '',
contract_years: 3,
age: 24,
injuries_24m: 10,
asking_price: 45,
market_value_estimation: 40,
})
const [result, setResult] = useState<EvalResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showLog, setShowLog] = useState(false)
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
// Auto-Format the Player Name so the PDF always looks professional
let cleanName = form.selected_name.trim()
// Apply Title Case for unrecognized players (e.g. "john doe" -> "John Doe")
cleanName = cleanName.replace(/\b\w/g, c => c.toUpperCase())
if (cleanName !== form.selected_name) {
setForm(prev => ({ ...prev, selected_name: cleanName }))
}
if (!cleanName) { setError('Player name is required.'); return }
if (!incrementUsage()) return;
setLoading(true); setError(null); setResult(null)
try {
const res = await fetch(`${API_URL}/api/evaluate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `API error ${res.status}`)
}
setResult(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error — is the API running?')
} finally {
setLoading(false)
}
}
const handleDownloadPdf = () => {
// Temporarily change document title so the native Print/Save dialog
// automatically uses this as the default PDF file name.
const originalTitle = document.title
const safeName = form.selected_name.replace(/\s+/g, '_') || 'Player'
document.title = `FairValue_Report_${safeName}`
// Rely on native browser print for high-fidelity, copyable vectorized PDFs
window.print()
// Restore the original title after the print dialog resolves
setTimeout(() => {
document.title = originalTitle
}, 1000)
}
const L = result?.ledger
return (
<div className="page">
<div className="no-print">
{isLocked && <AccessModal />}
<div className="container">
{/* Header */}
<div style={{ marginBottom:36 }}>
<span className="badge badge-green" style={{ marginBottom:10, display:'inline-flex' }}>AI Valuation Engine</span>
<h1 style={{ marginBottom:8 }}>Strategic Transfer Estimator</h1>
<p>Evaluate a transfer ceiling guided by ML valuations and multi-axis NLP intelligence.</p>
</div>
<div style={{ display:'grid', gridTemplateColumns:'380px 1fr', gap:24, alignItems:'start' }}>
{/* ── Left: Form ────────────────────────────────────────────────── */}
<div style={{ display:'flex', flexDirection:'column', gap:16 }}>
<div className="glass-flat" style={{ padding:24 }}>
<h3 style={{ marginBottom:20, color:'var(--text-1)' }}>Player Profile</h3>
<div style={{ display:'flex', flexDirection:'column', gap:14 }}>
<div className="input-group">
<label className="field-label">Player Name *</label>
<input className="input" placeholder="e.g. Jude Bellingham"
value={form.selected_name}
onChange={e => set('selected_name', e.target.value)}/>
<small style={{ fontSize:'0.7rem', color:'var(--text-3)' }}>
Use the Transfermarkt name for best accuracy.
</small>
</div>
<div className="grid-2">
<div className="input-group">
<label className="field-label">Current Club</label>
<input className="input" placeholder="Real Madrid"
value={form.current_club}
onChange={e => set('current_club', e.target.value)}/>
</div>
<div className="input-group">
<label className="field-label">Buying Club</label>
<input className="input" placeholder="Arsenal"
value={form.interested_club}
onChange={e => set('interested_club', e.target.value)}/>
</div>
</div>
</div>
</div>
<div className="glass-flat" style={{ padding:24 }}>
<h3 style={{ marginBottom:20 }}>Financial Parameters</h3>
<div style={{ display:'flex', flexDirection:'column', gap:18 }}>
{/* Contract Years */}
<div className="input-group">
<label className="field-label">
Contract Years Remaining
<span className="range-val" style={{ float:'right' }}>{form.contract_years}yr</span>
</label>
<input type="range" min={0.5} max={7} step={0.5}
value={form.contract_years}
onChange={e => set('contract_years', parseFloat(e.target.value))}/>
<div className="range-row"><span>0.5yr</span><span>7yr</span></div>
</div>
{/* Age */}
<div className="input-group">
<label className="field-label">
Age
<span className="range-val" style={{ float:'right' }}>{form.age}</span>
</label>
<input type="range" min={16} max={40} step={1}
value={form.age}
onChange={e => set('age', parseInt(e.target.value))}/>
<div className="range-row"><span>16</span><span>40</span></div>
</div>
{/* Injury days */}
<div className="input-group">
<label className="field-label">Injury Days (last 24m)</label>
<input type="number" className="input" min={0} max={730}
value={form.injuries_24m}
onChange={e => set('injuries_24m', parseInt(e.target.value)||0)}/>
</div>
{/* Market value */}
<div className="input-group">
<label className="field-label">Current Market Value (£m)</label>
<input type="number" className="input" min={0.1} step={0.5}
value={form.market_value_estimation}
onChange={e => set('market_value_estimation', parseFloat(e.target.value)||0)}/>
</div>
{/* Asking price */}
<div className="input-group">
<label className="field-label">Selling Club Asking Price (£m)</label>
<input type="number" className="input" min={0.1} step={0.5}
value={form.asking_price}
onChange={e => set('asking_price', parseFloat(e.target.value)||0)}/>
</div>
</div>
</div>
<button className="btn btn-primary" style={{ width:'100%', justifyContent:'center', padding:14 }}
onClick={handleSubmit} disabled={loading}>
{loading
? <><ButtonPulse /> Fetching live intel… (15–30s)</>
: '⚡ Calculate Hard Cap'}
</button>
{error && <div className="alert alert-danger">{error}</div>}
</div>
{/* ── Right: Results ────────────────────────────────────────────── */}
<div>
{!result && !loading && (
<div className="glass" style={{ padding:48, textAlign:'center', color:'var(--text-3)' }}>
<div style={{ fontSize:'3rem', marginBottom:16 }}>📊</div>
<p>Results will appear here after evaluation.</p>
</div>
)}
{loading && (
<div className="glass" style={{ padding:48, textAlign:'center' }}>
<AILoader />
<p style={{ color: 'var(--text-1)', fontWeight: 500 }}>Running ML inference + scraping live market signals…</p>
<p style={{ fontSize:'0.8rem', color:'var(--text-3)', marginTop:8 }}>
Live DDGS intel can take 15–30 seconds first time.
</p>
</div>
)}
{result && L && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="animate-in"
style={{ display:'flex', flexDirection:'column', gap:18 }}
>
{/* Verdict alert */}
<div style={{ display: 'flex', gap: '12px', alignItems: 'stretch' }}>
<div className={`alert ${form.asking_price > L.hard_cap ? 'alert-danger' : 'alert-success'}`} style={{ flex: 1, margin: 0 }}>
{form.asking_price > L.hard_cap
? `⚠️ OVERPAY RISK — Asking price £${form.asking_price}m exceeds our Fair Value ceiling of £${L.hard_cap.toFixed(1)}m by £${(form.asking_price - L.hard_cap).toFixed(1)}m.`
: `✅ FAIR DEAL — Asking price £${form.asking_price}m is within the £${L.hard_cap.toFixed(1)}m Fair Value. Proceed with confidence.`}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<button onClick={handleDownloadPdf} className="btn btn-secondary" style={{ padding: '0 24px' }} title="Download Professional PDF Report">
<Download size={20} />
Download PDF Report
</button>
<span style={{ fontSize: '0.68rem', color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.4 }}>
💡 In the print dialog, set <strong style={{ color: 'var(--text-1)' }}>Destination → Save as PDF</strong>
</span>
</div>
</div>
{/* Gauge + Ledger */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:18 }}>
<div className="glass" style={{ padding:20, display:'flex', alignItems:'center', justifyContent:'center' }}>
<Gauge asking={form.asking_price} hardCap={L.hard_cap}/>
</div>
<div className="glass" style={{ padding:24 }}>
<h3 style={{ marginBottom:20 }}>XAI Valuation Ledger</h3>
<div style={{ display:'flex', flexDirection:'column', gap:16 }}>
<div>
<div className="metric-label">Intrinsic Performance Value</div>
<div className="metric-value" style={{ color:'var(--profit-color)' }}>£{L.intrinsic_performance_value.toFixed(1)}m</div>
<span className="badge badge-green" style={{ marginTop:4 }}>{L.category}</span>
</div>
<div className="divider" style={{ margin:'8px 0' }}/>
<div>
<div className="metric-label">Age &amp; Contract Impact &mdash; {L.depreciation > 0 ? '📉 Depreciation' : '📈 Appreciation'}</div>
<div className="metric-value" style={{ color: L.depreciation > 0 ? 'var(--loss-color)' : 'var(--profit-color)' }}>
{L.depreciation > 0 ? '-' : '+'}£{Math.abs(L.depreciation).toFixed(1)}m
</div>
<span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated</span>
</div>
<div className="divider" style={{ margin:'8px 0' }}/>
<div>
<div className="metric-label">ML Baseline</div>
<div className="metric-value">£{L.baseline_value.toFixed(1)}m</div>
</div>
<div>
<div className="metric-label">NLP Multiplier</div>
<div className="metric-value" style={{ color: L.external_multiplier > 1 ? 'var(--profit-color)' : 'var(--loss-color)' }}>
×{L.external_multiplier.toFixed(3)}
</div>
</div>
<div style={{ padding:'12px 14px', background:'rgba(34,197,94,0.15)', borderRadius:10, border:'1px solid rgba(0,232,122,0.2)' }}>
<div className="metric-label">🎯 Fair Value Ceiling</div>
<div style={{ fontSize:'2.2rem', fontWeight:900, color:'var(--profit-color)', letterSpacing:'-0.03em' }}>
£{L.hard_cap.toFixed(1)}m
</div>
</div>
</div>
</div>
</div>
{/* SHAP Chart */}
<div className="glass" style={{ padding:24 }}>
<div style={{ marginBottom:14 }}>
<h3>SHAP Deep Dive — Top 10 Valuation Drivers</h3>
<p style={{ fontSize:'0.82rem', marginTop:4 }}>
Green = value driver. Red = value drag. Measured in log-price space.
</p>
</div>
<ShapChart data={result.shap_data}/>
</div>
{/* NLP Results */}
<div className="glass" style={{ padding:24 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<h3>Live Market Intelligence</h3>
{result.nlp_cached && <span className="badge badge-blue">Cached</span>}
</div>
<div style={{ display:'flex', flexDirection:'column', gap:14 }}>
<SentimentScore label="Injury / Availability" icon="🏥" value={result.nlp_results.durability} found={result.nlp_found}/>
<SentimentScore label="Recent Form & Impact" icon="📈" value={result.nlp_results.recency} found={result.nlp_found}/>
<SentimentScore label="Transfer Speculation" icon="🗞️" value={result.nlp_results.agent} found={result.nlp_found}/>
</div>
<div style={{ marginTop:16 }}>
<button className="btn btn-ghost" style={{ fontSize:'0.78rem', padding:'6px 14px' }}
onClick={() => setShowLog(!showLog)}>
{showLog ? '▲ Hide' : '▼ Show'} Recon Log ({result.logs.length} entries)
</button>
{showLog && (
<div style={{ marginTop:12, background:'var(--bg-elevated-2)', borderRadius:8, padding:'12px 14px' }}>
{result.logs.map((l, i) => (
<div key={i} style={{ fontSize:'0.75rem', color:'var(--text-2)', padding:'3px 0', borderBottom:'1px solid var(--glass-border)' }}>{l}</div>
))}
</div>
)}
</div>
</div>
</motion.div>
)}
</div>
</div>
{/* Definition of Terms Section */}
<DefinitionOfTerms />
</div>
</div>
{/* Hidden Report Template for PDF Generation */}
{result && <ReportTemplate ref={reportRef} form={form} result={result} />}
</div>
)
}