feat: Add dynamic Fundamental Factor Lens to individual stock search view
Build Institutional Trader / build-and-deploy (push) Failing after 17s
Details
Build Institutional Trader / build-and-deploy (push) Failing after 17s
Details
This commit is contained in:
parent
ae21b0df90
commit
ab1e3f28f6
|
|
@ -43,6 +43,10 @@ function assignPercentiles(raw, factorKey) {
|
|||
* Generates the descriptive Factor Profile for the entire universe based on CURRENT data.
|
||||
* Does NOT generate forward predictions.
|
||||
*/
|
||||
let _cachedRawProfiles = null;
|
||||
let _cachedZScoreMaps = null;
|
||||
let _cachedRawTime = 0;
|
||||
|
||||
export async function buildFactorProfiles(injectedUniverseRecords = null) {
|
||||
const rawProfiles = [];
|
||||
|
||||
|
|
@ -107,6 +111,13 @@ export async function buildFactorProfiles(injectedUniverseRecords = null) {
|
|||
// Cross-sectional standardization and percentile assignment
|
||||
[...factors, 'composite'].forEach(f => assignPercentiles(rawProfiles, f));
|
||||
|
||||
// Cache the raw profiles for single-stock comparisons
|
||||
if (!injectedUniverseRecords) {
|
||||
_cachedRawProfiles = rawProfiles;
|
||||
_cachedZScoreMaps = zScoreMaps;
|
||||
_cachedRawTime = Date.now();
|
||||
}
|
||||
|
||||
// If this is a test injection, return the raw profiles with percentiles for testing
|
||||
if (injectedUniverseRecords) return rawProfiles.reduce((acc, p) => { acc[p.ticker] = p; return acc; }, {});
|
||||
|
||||
|
|
@ -123,3 +134,76 @@ export async function buildFactorProfiles(injectedUniverseRecords = null) {
|
|||
|
||||
return cleanedProfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a single stock's factor profile relative to the cached universe.
|
||||
*/
|
||||
export async function getSingleFactorProfile(symbol) {
|
||||
if (!_cachedRawProfiles || (Date.now() - _cachedRawTime > 120000)) {
|
||||
await buildFactorProfiles();
|
||||
}
|
||||
|
||||
// If it's already in the universe, just return it
|
||||
const existing = _cachedRawProfiles.find(p => p.ticker === symbol);
|
||||
if (existing) {
|
||||
return {
|
||||
ticker: existing.ticker,
|
||||
value: existing.valuePercentile,
|
||||
quality: existing.qualityPercentile,
|
||||
lowVol: existing.lowVolPercentile,
|
||||
momentum: existing.momentumPercentile,
|
||||
composite: existing.compositePercentile,
|
||||
_disclaimer: "Descriptive only. Shows where this stock ranks vs. the core universe."
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch out-of-universe data
|
||||
const data = await pullRawRecord(symbol);
|
||||
if (!data) return null;
|
||||
|
||||
const raw = {
|
||||
ticker: symbol,
|
||||
value: FACTORS.value(data),
|
||||
quality: FACTORS.quality(data),
|
||||
lowVol: FACTORS.lowVol(data),
|
||||
momentum: FACTORS.momentum(data)
|
||||
};
|
||||
|
||||
// Standardize single value using universe mean/std from zScoreMaps... wait, standardizer only gives cross-sectional z-scores.
|
||||
// We can just re-run standardizer with the universe + this one stock.
|
||||
const tempRaw = [..._cachedRawProfiles, raw];
|
||||
|
||||
const factors = ['value', 'quality', 'lowVol', 'momentum'];
|
||||
const zScoreMaps = {};
|
||||
|
||||
for (const f of factors) {
|
||||
const rawByTicker = {};
|
||||
for (const r of tempRaw) {
|
||||
if (r[f] !== null && r[f] !== undefined) rawByTicker[r.ticker] = r[f];
|
||||
}
|
||||
zScoreMaps[f] = standardizeCrossSection(rawByTicker, false);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const f of factors) {
|
||||
const z = zScoreMaps[f][raw.ticker];
|
||||
if (z !== undefined) {
|
||||
sum += z;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
raw.composite = count > 0 ? (sum / count) : null;
|
||||
|
||||
[...factors, 'composite'].forEach(f => assignPercentiles(tempRaw, f));
|
||||
|
||||
return {
|
||||
ticker: raw.ticker,
|
||||
value: raw.valuePercentile,
|
||||
quality: raw.qualityPercentile,
|
||||
lowVol: raw.lowVolPercentile,
|
||||
momentum: raw.momentumPercentile,
|
||||
composite: raw.compositePercentile,
|
||||
_disclaimer: "Descriptive only. Shows where this out-of-universe stock ranks vs. the core universe today."
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,12 +102,15 @@ router.get('/stock/:symbol', async (req, res) => {
|
|||
const meta = getSymbolMeta(symbol);
|
||||
const analysis = await fetchFullAnalysis(symbol);
|
||||
|
||||
const profile = await getSingleFactorProfile(symbol);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...analysis,
|
||||
meta,
|
||||
capTier: classifyCapTier(analysis.quote?.market_cap ?? null, meta?.isETF ?? false),
|
||||
profile,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -214,7 +217,7 @@ router.get('/leaderboard', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
import { buildFactorProfiles } from '../factorlab/descriptiveLens.js';
|
||||
import { buildFactorProfiles, getSingleFactorProfile } from '../factorlab/descriptiveLens.js';
|
||||
|
||||
/**
|
||||
* GET /api/market/screener
|
||||
|
|
@ -270,9 +273,11 @@ router.get('/search/:symbol', async (req, res) => {
|
|||
meta?.isETF ?? false
|
||||
);
|
||||
|
||||
const profile = await getSingleFactorProfile(symbol);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { ...analysis, meta, capTier, suitability },
|
||||
data: { ...analysis, meta, capTier, suitability, profile },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[market/search/${req.params.symbol}]`, err);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,73 @@ function DirectionArrow({ dir }) {
|
|||
return <span className="text-slate-500">—</span>;
|
||||
}
|
||||
|
||||
function pctBar(pct) {
|
||||
if (pct == null) return <span className="text-slate-600 text-xs italic">Missing</span>;
|
||||
|
||||
let colorClass = 'bg-yellow-500'; // middle 50%
|
||||
if (pct >= 75) colorClass = 'bg-emerald-500'; // top 25%
|
||||
else if (pct < 25) colorClass = 'bg-red-500'; // bottom 25%
|
||||
|
||||
return (
|
||||
<div className="flex-1 mx-3 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${colorClass} transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorLens({ profile }) {
|
||||
if (!profile) return null;
|
||||
const isIncomplete = profile.value == null || profile.quality == null || profile.lowVol == null || profile.momentum == null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900/50 border border-slate-700/50 rounded-xl p-4 mb-6 relative group">
|
||||
<div className="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
|
||||
<span className="font-bold text-white text-sm flex items-center gap-2">
|
||||
📊 Fundamental Factor Lens
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold bg-slate-800 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30">
|
||||
Descriptive Only
|
||||
</span>
|
||||
</span>
|
||||
{isIncomplete && (
|
||||
<span className="text-[10px] text-orange-400 font-semibold bg-orange-950/50 px-2 py-1 rounded border border-orange-800/50">
|
||||
⚠️ INCOMPLETE DATA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Value</span>
|
||||
{pctBar(profile.value)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.value != null ? `${profile.value}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Quality</span>
|
||||
{pctBar(profile.quality)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.quality != null ? `${profile.quality}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Low-Vol</span>
|
||||
{pctBar(profile.lowVol)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.lowVol != null ? `${profile.lowVol}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Momentum</span>
|
||||
{pctBar(profile.momentum)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.momentum != null ? `${profile.momentum}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center w-5 h-5 rounded-full bg-slate-800 text-slate-400 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" title={profile._disclaimer}>
|
||||
i
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StockDetailPanel({ symbol, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [stockData, setStockData] = useState(null);
|
||||
|
|
@ -183,7 +250,7 @@ export default function StockDetailPanel({ symbol, onClose }) {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} />}
|
||||
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} profile={stockData?.profile} />}
|
||||
{activeTab === 'fundamentals' && <FundamentalsTab f={f} />}
|
||||
{activeTab === 'technicals' && <TechnicalsTab t={t} q={q} />}
|
||||
{activeTab === 'holders' && <HoldersTab data={holders} loading={holdersLoading} />}
|
||||
|
|
@ -195,9 +262,11 @@ export default function StockDetailPanel({ symbol, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ q, f, t }) {
|
||||
function OverviewTab({ q, f, t, profile }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
{profile && <FactorLens profile={profile} />}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Price Snapshot</div>
|
||||
<table className="w-full">
|
||||
|
|
@ -231,6 +300,7 @@ function OverviewTab({ q, f, t }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue