feat: dynamic hype cycle + market signal engine + eBay/CapEx panels

6-source composite Market Signal Score (0-100) per transceiver technology.
New GET /api/hype-cycle/market-signals blends: Norton-Bass hype_score,
hyperscaler CapEx YoY (MSFT +68.8%, GOOG +107%, META +46.8%), price
observation activity ratio 30d vs prior 30d, AI cluster transceiver demand,
eBay secondary market sell-through velocity, internal fast-mover trend.
All 6 queries run in parallel via Promise.all().

Recommendation engine maps hype phase × capex boom × speed class →
Buy/Hold/Watch labels with tooltips. Dashboard Hype Cycle table now shows
Market Signal ● LIVE column + Recommendation column. Hyperscaler CapEx
panel + eBay panel added to hype tab. Procurement: new eBay Market section.
Sourcing Hype Cycle replaced hardcoded seed with live price observation data.
This commit is contained in:
Rene Fichtmueller 2026-05-14 16:17:52 +02:00
parent 13fe33eceb
commit 10d13633fb
6 changed files with 704 additions and 33 deletions

View File

@ -1,6 +1,7 @@
# TIP Changelog # TIP Changelog
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."} {"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."} {"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."}

View File

@ -22,25 +22,112 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325
// ── Runtime-switchable provider state ────────────────────────────────────── // ── Runtime-switchable provider state ──────────────────────────────────────
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch). // Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch).
// Falls back to process.env, then to defaults. No restart required for switches. // Falls back to process.env, then to defaults. No restart required for switches.
//
// AUTO-DISCOVERY: At startup and on a periodic refresh, the active fo-blog-v* model
// is validated against Ollama's actual model list. If the configured model no longer
// exists (e.g. Magatama trained a new version and Ollama removed older tags), the
// highest available fo-blog-v* version is picked automatically — no manual env or
// settings-file update needed after each training cycle.
const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json"); const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json");
const STATIC_FALLBACK_MODEL = "fo-blog-v10";
const DISCOVERY_REFRESH_MS = Number.parseInt(process.env.BLOG_LLM_DISCOVERY_REFRESH_MS || "", 10) || 10 * 60_000;
interface LlmSettings { provider: string; ollamaModel: string } interface LlmSettings { provider: string; ollamaModel: string }
function loadSettings(): LlmSettings { function loadSettingsRaw(): LlmSettings {
try { try {
if (existsSync(SETTINGS_FILE)) { if (existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings; const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>;
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" }; return {
provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama",
ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
};
} }
} catch { /* ignore corrupt file */ } } catch { /* ignore corrupt file */ }
return { return {
provider: process.env.BLOG_LLM_PROVIDER || "ollama", provider: process.env.BLOG_LLM_PROVIDER || "ollama",
ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7", ollamaModel: process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
}; };
} }
let _settings = loadSettings(); /**
* Sort fo-blog-v{N}[-r{M}] tags newest-first.
*
* Magatama convention (confirmed by /api/llm/status?lane=fo_blogllm):
* - `fo-blog-vN` base tag, the active production model after adoption
* - `fo-blog-vN-rM` revision metadata, intermediate adapter save
*
* So within the same major N, the BASE tag wins over -rM revisions.
*
* Order: higher N > lower N; within same N, base ("no -r") > any -rM revision.
*/
function compareFoBlogVersionsDesc(a: string, b: string): number {
const re = /^fo-blog-v(\d+)(?:-r(\d+))?$/;
const ma = re.exec(a);
const mb = re.exec(b);
if (!ma || !mb) return a.localeCompare(b);
const va = Number.parseInt(ma[1], 10);
const vb = Number.parseInt(mb[1], 10);
if (va !== vb) return vb - va;
// Same major version: base tag (no -r suffix) wins over any -rM revision
const aIsBase = ma[2] === undefined;
const bIsBase = mb[2] === undefined;
if (aIsBase !== bIsBase) return aIsBase ? -1 : 1;
// Both have -rM: higher M is newer
const ra = ma[2] ? Number.parseInt(ma[2], 10) : 0;
const rb = mb[2] ? Number.parseInt(mb[2], 10) : 0;
return rb - ra;
}
interface OllamaTag { name: string }
interface OllamaTagsResponse { models: OllamaTag[] }
/** Probe Ollama for available fo-blog-v* models. Returns [] on any error (non-fatal). */
async function fetchOllamaFoBlogTags(): Promise<string[]> {
try {
const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) return [];
const data = await resp.json() as OllamaTagsResponse;
return (data.models || [])
.map(m => m.name.replace(/:latest$/, ""))
.filter(n => /^fo-blog-v\d+(?:-r\d+)?$/.test(n));
} catch {
return [];
}
}
/**
* Reconcile configured model against Ollama reality.
*
* Priority:
* 1. Configured model (env or settings file) if Ollama actually serves it
* 2. Highest fo-blog-v* version Ollama actually serves auto-discovered
* 3. Static fallback STATIC_FALLBACK_MODEL last resort
*
* Non-blocking: any Ollama failure leaves _settings untouched.
*/
async function reconcileWithOllama(): Promise<void> {
const configured = _settings.ollamaModel;
if (!configured.startsWith("fo-blog-v")) return; // only manage fo-blog-* lane
const available = await fetchOllamaFoBlogTags();
if (available.length === 0) return;
if (available.includes(configured)) return; // configured model still exists
const sorted = [...available].sort(compareFoBlogVersionsDesc);
const winner = sorted[0];
if (!winner || winner === configured) return;
console.log(`[LLM] auto-discovery: configured "${configured}" not in Ollama; switching to latest available "${winner}" (candidates: ${sorted.join(", ")})`);
_settings = { ..._settings, ollamaModel: winner };
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
}
let _settings = loadSettingsRaw();
// Fire-and-forget initial reconciliation. Subsequent refresh runs every DISCOVERY_REFRESH_MS.
void reconcileWithOllama();
setInterval(() => { void reconcileWithOllama(); }, DISCOVERY_REFRESH_MS).unref();
/** Switch the active LLM provider at runtime. Persists to settings file. */ /** Switch the active LLM provider at runtime. Persists to settings file. */
export function setLlmProvider(provider: string, ollamaModel?: string): void { export function setLlmProvider(provider: string, ollamaModel?: string): void {
@ -52,6 +139,15 @@ export function setLlmProvider(provider: string, ollamaModel?: string): void {
/** Returns the currently active provider config. */ /** Returns the currently active provider config. */
export function getLlmProvider(): LlmSettings { return { ..._settings }; } export function getLlmProvider(): LlmSettings { return { ..._settings }; }
/**
* Force an immediate auto-discovery reconciliation against Ollama.
* Returns the active settings after reconcile.
*/
export async function refreshLlmAutoDiscovery(): Promise<LlmSettings> {
await reconcileWithOllama();
return { ..._settings };
}
// Convenience getters used below (re-read on every call for zero-latency switch) // Convenience getters used below (re-read on every call for zero-latency switch)
function provider(): string { return _settings.provider; } function provider(): string { return _settings.provider; }
function llmModel(): string { return _settings.ollamaModel; } function llmModel(): string { return _settings.ollamaModel; }

View File

@ -165,6 +165,242 @@ hypeCycleRouter.get("/regional/:tech", (req: Request, res: Response) => {
}); });
}); });
// ── MARKET SIGNAL COMPUTATION ───────────────────────────────────────────────
/** Technology → form-factor/speed mapping for cross-table signal aggregation */
const TECH_SIGNAL_MAP = [
{ label: "10G-SFP+", speedGbps: 10, formFactors: ["SFP+"], speedLabel: "10G" },
{ label: "100G-QSFP28", speedGbps: 100, formFactors: ["QSFP28"], speedLabel: "100G" },
{ label: "400G-QSFP-DD", speedGbps: 400, formFactors: ["QSFP-DD"], speedLabel: "400G" },
{ label: "800G-OSFP", speedGbps: 800, formFactors: ["OSFP"], speedLabel: "800G" },
{ label: "1.6T-OSFP", speedGbps: 1600, formFactors: ["OSFP"], speedLabel: "1.6T" },
{ label: "400G-ZR", speedGbps: 400, formFactors: ["SFP-DD", "CFP2"], speedLabel: "400G" },
] as const;
type PhaseKey =
| "plateau_productivity" | "slope_enlightenment" | "trough_disillusionment"
| "peak_inflated_expectations" | "innovation_trigger";
function buildRecommendation(
phase: PhaseKey,
signalScore: number,
capexYoyAvg: number,
speedGbps: number,
): { label: string; color: string; detail: string } {
const fast = speedGbps >= 400;
const capexBoom = capexYoyAvg > 50;
switch (phase) {
case "plateau_productivity":
if (fast && capexBoom)
return { label: "🚀 Buy — AI Wave", color: "#16a34a", detail: "Commodity pricing + AI infrastructure demand surge. Stock up now." };
if (signalScore >= 85)
return { label: "✅ Hold — Stable", color: "#2563eb", detail: "Mature commodity market, stable long-term demand." };
return { label: "📦 Hold", color: "#64748b", detail: "Commodity market. Price-driven. Order on demand." };
case "slope_enlightenment":
if (capexBoom)
return { label: "🟢 Buy Now", color: "#16a34a", detail: "Growing mainstream adoption + hyperscaler capex boom. Window closing." };
return { label: "🟡 Buy", color: "#ca8a04", detail: "Adoption curve steepening. Monitor pricing before large orders." };
case "trough_disillusionment":
if (signalScore > 50)
return { label: "🔍 Buy Opportunity", color: "#7c3aed", detail: "Hype trough but demand signals emerging. Strategic buying window." };
return { label: "⏳ Watch", color: "#94a3b8", detail: "Wait for demand confirmation before stocking." };
case "peak_inflated_expectations":
if (fast && capexBoom)
return { label: "⚡ Caution / Buy", color: "#f97316", detail: "Hype peak but real hyperscaler demand. Buy selectively, not speculatively." };
return { label: "⚠ Caution", color: "#ef4444", detail: "Peak hype. Verify real end-customer demand before building inventory." };
case "innovation_trigger":
return { label: "👁 Watch", color: "#94a3b8", detail: "Early stage — too early for volume commitments. Monitor for traction." };
default:
return { label: "📊 Monitor", color: "#64748b", detail: "Monitor signals." };
}
}
// GET /api/hype-cycle/market-signals — Multi-source demand intelligence
hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => {
try {
const { pool } = await import("../db/client");
// ── Parallel data fetch ──────────────────────────────────────────────────
const [hypeRows, priceRows, capexRows, aiRows, ebayRows, internalRows] = await Promise.all([
// Latest hype cycle per technology
pool.query(`
SELECT DISTINCT ON (technology)
technology, hype_phase, hype_score, asp_current_usd,
r_squared, computed_at, current_share, years_to_next_phase
FROM hype_cycle_analysis
ORDER BY technology, computed_at DESC
`),
// Price observation activity: last 30d vs prior 30d (scraping frequency = demand proxy)
pool.query(`
SELECT
t.speed_gbps,
t.form_factor,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days'
AND po.time < NOW() - INTERVAL '30 days') AS obs_prior_30d
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.time >= NOW() - INTERVAL '60 days'
GROUP BY t.speed_gbps, t.form_factor
`),
// Hyperscaler capex — most recent per company
pool.query(`
SELECT DISTINCT ON (company)
company, period_label, capex_usd_millions,
dc_capex_est_millions, yoy_growth_pct
FROM hyperscaler_capex
WHERE yoy_growth_pct IS NOT NULL
ORDER BY company, period_end DESC
`),
// AI cluster demand last 90 days
pool.query(`
SELECT
COALESCE(SUM(estimated_transceivers), 0) AS total_tx,
COUNT(*) AS cluster_count,
COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates
FROM ai_cluster_announcements
WHERE announced_date >= NOW() - INTERVAL '90 days'
`),
// eBay marketplace velocity
pool.query(`
SELECT DISTINCT ON (form_factor, speed_label)
marketplace, keyword, form_factor, speed_label,
sold_count_30d, active_listings, avg_sold_price
FROM marketplace_velocity
WHERE sold_count_30d IS NOT NULL
ORDER BY form_factor, speed_label, scraped_at DESC
`),
// Internal demand: fast-mover trend
pool.query(`
SELECT
COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct > 0) AS fast_mover_pos,
COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct < 0) AS fast_mover_neg,
AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'fast_mover') AS avg_fast_trend,
AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'regular') AS avg_regular_trend
FROM flexoptix_internal_demand
WHERE is_internal = true
`),
]);
// ── Pre-compute global signals ───────────────────────────────────────────
const capexYoyValues = capexRows.rows
.map((r) => parseFloat(r.yoy_growth_pct))
.filter((v) => !isNaN(v));
const capexYoyAvg = capexYoyValues.length
? capexYoyValues.reduce((a, b) => a + b, 0) / capexYoyValues.length
: 0;
const aiData = aiRows.rows[0];
const totalAiTx = parseInt(aiData?.total_tx ?? "0") || 0;
const internalData = internalRows.rows[0];
const avgFastTrend = parseFloat(internalData?.avg_fast_trend ?? "0") || 0;
// Build price activity map: speedGbps+formFactor → ratio (30d/prior30d)
const priceMap = new Map<string, number>();
for (const row of priceRows.rows) {
const obs30 = parseInt(row.obs_30d) || 0;
const obsPrior = parseInt(row.obs_prior_30d) || 1;
const key = `${row.speed_gbps}__${row.form_factor ?? ""}`;
priceMap.set(key, obs30 / obsPrior);
}
// Build eBay map: speedLabel → sold_count_30d
const ebayMap = new Map<string, number>();
for (const row of ebayRows.rows) {
const speed = (row.speed_label ?? "").replace(/\s+/g, "");
ebayMap.set(speed, parseInt(row.sold_count_30d) || 0);
}
// ── Per-technology signals ───────────────────────────────────────────────
const technologies = hypeRows.rows.map((r) => {
const tech = TECH_SIGNAL_MAP.find((t) => t.label === r.technology);
const phase = (r.hype_phase ?? "innovation_trigger") as PhaseKey;
const hypeScore = parseInt(r.hype_score) || 0;
// Price activity ratio: average across matching speed+formFactor combos
let priceRatios: number[] = [];
if (tech) {
for (const ff of tech.formFactors) {
const key = `${tech.speedGbps}__${ff}`;
const ratio = priceMap.get(key);
if (ratio !== undefined) priceRatios.push(ratio);
}
}
const priceActivityRatio = priceRatios.length
? priceRatios.reduce((a, b) => a + b, 0) / priceRatios.length
: 1;
// eBay velocity for this speed
const ebayVelocity = tech ? (ebayMap.get(tech.speedLabel) ?? 0) : 0;
// AI cluster: allocate demand proportionally for high-speed techs
const aiBoostTx = tech && tech.speedGbps >= 400 ? totalAiTx : 0;
// Compute composite score (0100)
let score = hypeScore * 0.3;
const capexBoostPts = capexYoyAvg > 100 ? 18 : capexYoyAvg > 50 ? 12 : capexYoyAvg > 20 ? 5 : 0;
const priceBoostPts = priceActivityRatio > 1.3 ? 10 : priceActivityRatio > 1.0 ? 5 : -3;
const aiBoostPts = aiBoostTx > 100000 ? 14 : aiBoostTx > 50000 ? 9 : aiBoostTx > 10000 ? 4 : 0;
const ebayBoostPts = ebayVelocity > 200 ? 8 : ebayVelocity > 100 ? 5 : ebayVelocity > 50 ? 2 : 0;
const intlBoostPts = avgFastTrend > 10 ? 6 : avgFastTrend > 0 ? 3 : avgFastTrend < -20 ? -5 : 0;
score += capexBoostPts + priceBoostPts + aiBoostPts + ebayBoostPts + intlBoostPts;
const marketSignalScore = Math.max(0, Math.min(100, Math.round(score)));
const rec = buildRecommendation(phase, marketSignalScore, capexYoyAvg, tech?.speedGbps ?? 0);
// Signal drivers list for tooltip
const drivers: string[] = [];
if (capexBoostPts > 0) drivers.push(`Hyperscaler CapEx +${capexYoyAvg.toFixed(0)}% YoY avg`);
if (priceActivityRatio > 1.1) drivers.push(`Price obs +${((priceActivityRatio - 1) * 100).toFixed(0)}% MoM activity`);
if (aiBoostTx > 0) drivers.push(`~${(aiBoostTx / 1000).toFixed(0)}k transceivers in AI cluster builds`);
if (ebayVelocity > 0) drivers.push(`${ebayVelocity} units sold on secondary market (30d)`);
if (intlBoostPts > 0) drivers.push(`Internal fast-movers trending ${avgFastTrend > 0 ? "+" : ""}${avgFastTrend.toFixed(1)}%`);
return {
technology: r.technology,
phase,
hypeScore,
aspCurrentUsd: r.asp_current_usd,
marketSignalScore,
recommendation: rec,
drivers,
speedGbps: tech?.speedGbps,
priceActivityRatio: Math.round(priceActivityRatio * 100) / 100,
ebayVelocity,
computedAt: r.computed_at,
};
});
// ── Global context ───────────────────────────────────────────────────────
const globalContext = {
hyperscalerCapex: capexRows.rows.map((r) => ({
company: r.company,
periodLabel: r.period_label,
capexMillions: parseFloat(r.capex_usd_millions),
dcCapexMillions: parseFloat(r.dc_capex_est_millions),
yoyGrowthPct: parseFloat(r.yoy_growth_pct),
})),
capexYoyAvg: Math.round(capexYoyAvg),
capexBoom: capexYoyAvg > 50,
totalAiClusterTx90d: totalAiTx,
aiClusterCount90d: parseInt(aiData?.cluster_count ?? "0") || 0,
internalFastMoverTrend: Math.round(avgFastTrend * 10) / 10,
};
res.json({ success: true, technologies, globalContext, computed_at: new Date().toISOString() });
} catch (err) {
console.error("Market signals error:", err);
res.status(500).json({ success: false, error: "Failed to compute market signals" });
}
});
// GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table) // GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => { hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
try { try {

View File

@ -424,3 +424,79 @@ procurementRouter.get("/internal-demand", async (req: Request, res: Response) =>
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
}); });
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/hyperscaler-capex
// Hyperscaler quarterly CapEx from SEC filings — demand context for transceivers
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/hyperscaler-capex", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
company, period_label, period_end,
capex_usd_millions, dc_capex_est_millions,
yoy_growth_pct, filing_type, source_url
FROM hyperscaler_capex
ORDER BY period_end DESC, capex_usd_millions DESC
`);
const summaryResult = await pool.query(`
SELECT
company,
MAX(period_end) AS latest_period_end,
MAX(period_label) AS latest_period,
(ARRAY_AGG(capex_usd_millions ORDER BY period_end DESC))[1] AS latest_capex,
(ARRAY_AGG(dc_capex_est_millions ORDER BY period_end DESC))[1] AS latest_dc_capex,
(ARRAY_AGG(yoy_growth_pct ORDER BY period_end DESC))[1] AS latest_yoy_growth,
AVG(yoy_growth_pct) FILTER (WHERE period_end >= NOW() - INTERVAL '365 days') AS avg_yoy_12m
FROM hyperscaler_capex
GROUP BY company
ORDER BY latest_capex DESC NULLS LAST
`);
res.json({
data: result.rows,
summary: summaryResult.rows,
});
} catch (err) {
console.error("Hyperscaler capex error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/marketplace-velocity
// Secondary market (eBay) sell-through as demand signal
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT DISTINCT ON (form_factor, speed_label)
marketplace, keyword, form_factor, speed_label,
sold_count_30d, active_listings, avg_sold_price,
min_price, max_price, currency, scraped_at
FROM marketplace_velocity
ORDER BY form_factor, speed_label, scraped_at DESC
`);
const hotResult = await pool.query(`
SELECT DISTINCT ON (form_factor, speed_label)
marketplace, form_factor, speed_label,
sold_count_30d, active_listings, avg_sold_price
FROM marketplace_velocity
WHERE sold_count_30d > 0
ORDER BY form_factor, speed_label, scraped_at DESC
`);
res.json({
data: result.rows,
hot: hotResult.rows.sort(
(a: { sold_count_30d: string }, b: { sold_count_30d: string }) =>
parseInt(b.sold_count_30d) - parseInt(a.sold_count_30d)
),
});
} catch (err) {
console.error("Marketplace velocity error:", err);
res.status(500).json({ error: "Internal server error" });
}
});

View File

@ -31,13 +31,28 @@
} }
blogPipelineRunning = true; blogPipelineRunning = true;
// Fetch the current active model name so we never show a stale hardcoded version.
var initialModelLabel = (window._activeFoBlogModel) || 'FO_BlogLLM';
fetch(API + '/api/blog/llm/status', { headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var m = data && data.llm && data.llm.model;
if (m) {
window._activeFoBlogModel = m;
var s = document.getElementById('bp-step');
if (s && s.textContent.indexOf('Connecting to FO_BlogLLM') === 0) {
s.textContent = 'Connecting to FO_BlogLLM (' + m + ')';
}
}
}).catch(function() {});
var pipelineEl = document.getElementById('blog-pipeline-status'); var pipelineEl = document.getElementById('blog-pipeline-status');
if (pipelineEl) { if (pipelineEl) {
pipelineEl.innerHTML = pipelineEl.innerHTML =
'<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' + '<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' +
'<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' + '<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' +
'<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' + '<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to FO_BlogLLM (fo-blog-v7)</div>' + '<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to ' + initialModelLabel + '</div>' +
'<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' + '<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' +
'<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' + '<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' +
'<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' + '<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
@ -137,7 +152,7 @@
if (bar) bar.style.width = prog.pct + '%'; if (bar) bar.style.width = prog.pct + '%';
if (pct) pct.textContent = prog.pct + '%'; if (pct) pct.textContent = prog.pct + '%';
if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); } if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); }
if (step) step.textContent = 'Step ' + prog.step + '/10 · fo-blog-v7 via adapter bridge'; if (step) step.textContent = 'Step ' + prog.step + '/10 · ' + (window._activeFoBlogModel || 'fo-blog-v10') + ' via adapter bridge';
} else { } else {
_stallCount++; _stallCount++;
// After 5 consecutive non-running polls (~40s), show stall warning // After 5 consecutive non-running polls (~40s), show stall warning

View File

@ -916,6 +916,11 @@
</div> </div>
<div id="hype-svg-container"></div> <div id="hype-svg-container"></div>
</div> </div>
<!-- Market Context cards — loaded dynamically from market-signals API -->
<div id="hype-market-context" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));margin-bottom:1.25rem">
<div class="loading pulse" style="grid-column:1/-1;padding:0.75rem">Loading market signals…</div>
</div>
<div class="card mt"> <div class="card mt">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -923,9 +928,8 @@
<th>Technology<span class="sort-arrow"></span></th> <th>Technology<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th> <th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th> <th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Cumulative market adoption based on Norton-Bass diffusion model. 0-100% of total addressable market. MODELL — keine echten Marktdaten.">Adoption <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th> <th class="tip" data-tip="Composite Market Signal Score (0100). Blends hype score + hyperscaler capex trend + price observation activity + AI cluster demand + eBay secondary market + internal demand velocity. Higher = stronger real-world demand signal.">Market Signal <span style="font-size:0.58rem;color:#16a34a;font-weight:600">● LIVE</span><span class="sort-arrow"></span></th>
<th class="tip" data-tip="Estimated year of peak hype — Norton-Bass model estimate, not real forecast data.">Peak <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th> <th class="tip" data-tip="Data-driven buy recommendation based on hype phase + multi-source market signals.">Recommendation<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Years until mainstream — Norton-Bass model estimate.">To Plateau <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current OEM ASP in USD — from Mouser/market data.">OEM ASP<span class="sort-arrow"></span></th> <th class="tip" data-tip="Current OEM ASP in USD — from Mouser/market data.">OEM ASP<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Bass model goodness-of-fit (R²). Higher = more reliable forecast."><span class="sort-arrow"></span></th> <th class="tip" data-tip="Bass model goodness-of-fit (R²). Higher = more reliable forecast."><span class="sort-arrow"></span></th>
</tr></thead> </tr></thead>
@ -934,7 +938,7 @@
</div> </div>
</div> </div>
<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim);display:flex;gap:1.5rem;flex-wrap:wrap"> <div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim);display:flex;gap:1.5rem;flex-wrap:wrap">
<span><span style="color:#6366f1;font-weight:700">[M]</span> = MODELL — Norton-Bass mathematische Schätzung, keine echten Marktdaten</span> <span><span style="color:#16a34a;font-weight:700">● LIVE</span> = Real market data: hyperscaler capex + price activity + AI cluster demand + eBay velocity + internal demand</span>
<span>OEM ASP = real (Mouser/Marktdaten)</span> <span>OEM ASP = real (Mouser/Marktdaten)</span>
</div> </div>
<div class="card mt" style="border-left:3px solid var(--cyan)"> <div class="card mt" style="border-left:3px solid var(--cyan)">
@ -974,6 +978,31 @@
<div class="loading pulse" style="padding:2.5rem;text-align:center">Loading sourcing data…</div> <div class="loading pulse" style="padding:2.5rem;text-align:center">Loading sourcing data…</div>
</div> </div>
</div> </div>
<!-- Hyperscaler CapEx panel -->
<div class="card mt" style="border-left:3px solid #2563eb">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">Hyperscaler CapEx <span style="font-size:0.72rem;font-weight:700;background:#2563eb22;color:#2563eb;border:1px solid #2563eb44;border-radius:3px;padding:1px 6px">SEC Filings</span></div>
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Quarterly infrastructure spend — primary demand driver for high-speed transceivers</div>
</div>
<div id="capex-avg-badge" style="font-size:0.82rem;font-weight:700;padding:4px 12px;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;color:#16a34a"></div>
</div>
<div id="capex-table-container">
<div class="loading pulse">Loading capex data…</div>
</div>
</div>
<!-- eBay Marketplace panel -->
<div class="card mt" style="border-left:3px solid #f97316">
<div style="margin-bottom:0.75rem">
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">eBay Secondary Market <span style="font-size:0.72rem;font-weight:700;background:#f9731622;color:#f97316;border:1px solid #f9731644;border-radius:3px;padding:1px 6px">Demand Signal</span></div>
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Secondary market sell-through rates — real end-buyer demand by form factor</div>
</div>
<div id="ebay-velocity-container">
<div class="loading pulse">Loading marketplace data…</div>
</div>
</div>
</div> </div>
</div> </div>
@ -1568,6 +1597,7 @@
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button> <button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
<button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button> <button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button>
<button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button> <button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button>
<button onclick="showProcSection('marketplace')" id="proc-btn-marketplace" class="proc-btn" style="background:rgba(249,115,22,0.08);border-color:rgba(249,115,22,0.3);color:#f97316">🛒 eBay Market</button>
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button> <button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button> <button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
<div style="flex:1"></div> <div style="flex:1"></div>
@ -1658,6 +1688,18 @@
</div> </div>
</div> </div>
<!-- eBay Marketplace section -->
<div id="proc-section-marketplace" style="display:none">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;align-items:center;flex-wrap:wrap">
<span style="font-size:0.8rem;font-weight:700;color:var(--text)">Secondary Market Demand (eBay)</span>
<div style="flex:1"></div>
<span style="font-size:0.72rem;color:var(--text-dim)">Higher sell-through = real end-buyer demand, price floor signal</span>
</div>
<div id="proc-marketplace-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
<div class="loading pulse">Loading marketplace data...</div>
</div>
</div>
<!-- AI Clusters section --> <!-- AI Clusters section -->
<div id="proc-section-ai-clusters" style="display:none"> <div id="proc-section-ai-clusters" style="display:none">
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats"> <div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats">
@ -3336,17 +3378,42 @@ async function loadHypeCycle() {
dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); }); dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
}); });
// Load market signals in parallel and enrich the table
var marketSignals = {};
var globalCtx = {};
try {
var msData = await api('/api/hype-cycle/market-signals');
if (msData.success && msData.technologies) {
msData.technologies.forEach(function(ms) { marketSignals[ms.technology] = ms; });
globalCtx = msData.globalContext || {};
renderHypeMarketContext(globalCtx, msData.technologies);
}
} catch(e) {
var ctxEl = el('hype-market-context');
if (ctxEl) ctxEl.innerHTML = '';
}
buildDOM(el('hype-table'), techs.map(function(t) { buildDOM(el('hype-table'), techs.map(function(t) {
var color = PC[t.phase] || '#8888a4'; var color = PC[t.phase] || '#8888a4';
// FIX: adoptionPct is already a percentage (0-100), do NOT multiply by 100 var ms = marketSignals[t.technology];
var adoptionDisplay = (t.adoptionPct != null ? t.adoptionPct : 0) + '%'; var signalHtml = '—';
var recHtml = '—';
if (ms) {
var sc = ms.marketSignalScore;
var scColor = sc >= 70 ? '#16a34a' : sc >= 50 ? '#ca8a04' : sc >= 30 ? '#f97316' : '#94a3b8';
var driversTip = ms.drivers && ms.drivers.length ? ms.drivers.join(' · ') : 'No signal data';
signalHtml = '<span class="tip" data-tip="' + esc(driversTip) + '" style="font-weight:700;color:' + scColor + ';font-family:monospace">' + sc + '</span>'
+ '<div style="width:60px;height:4px;background:var(--surface2);border-radius:2px;margin-top:3px;display:inline-block;vertical-align:middle;margin-left:6px"><div style="width:' + sc + '%;height:100%;background:' + scColor + ';border-radius:2px"></div></div>';
if (ms.recommendation) {
recHtml = '<span class="tip" data-tip="' + esc(ms.recommendation.detail) + '" style="font-size:0.72rem;font-weight:700;white-space:nowrap;color:' + ms.recommendation.color + '">' + esc(ms.recommendation.label) + '</span>';
}
}
return '<tr class="clickable" data-tech="' + esc(t.technology) + '">' return '<tr class="clickable" data-tech="' + esc(t.technology) + '">'
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>' + '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>'
+ '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>' + '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>'
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>' + '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>'
+ '<td class="mono tip" data-tip="' + esc(HYPE_TIPS['Adoption']) + '">' + adoptionDisplay + '</td>' + '<td>' + signalHtml + '</td>'
+ '<td class="mono">' + esc(t.peakYear || '—') + '</td>' + '<td>' + recHtml + '</td>'
+ '<td class="mono">' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
+ '<td class="mono">' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '</td>' + '<td class="mono">' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '</td>'
+ '<td class="mono">' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '</td>' + '<td class="mono">' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '</td>'
+ '</tr>'; + '</tr>';
@ -3369,6 +3436,9 @@ async function loadHypeCycle() {
// Render Sourcing Hype Cycle // Render Sourcing Hype Cycle
loadSourcingHypeCycle(); loadSourcingHypeCycle();
// Render Hyperscaler CapEx + eBay panels
loadHypeCapexAndEbay();
} }
// ── SOURCING HYPE CYCLE ────────────────────────────────────────────────────── // ── SOURCING HYPE CYCLE ──────────────────────────────────────────────────────
@ -3395,26 +3465,41 @@ async function loadSourcingHypeCycle() {
var meta = document.getElementById('sourcing-hype-meta'); var meta = document.getElementById('sourcing-hype-meta');
if (!container) return; if (!container) return;
var seedData = [ // Static fallback (only used if API fails)
{ label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 }, var seedFallback = [
{ label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 }, { label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 },
{ label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 }, { label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 },
{ label: 'QSFP+ 40G', speed_gbps: 40, form_factor: 'QSFP+', obs: 217, avg_price: 180.17 }, { label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 },
{ label: 'SFP28 25G', speed_gbps: 25, form_factor: 'SFP28', obs: 198, avg_price: 142.50 }, { label: 'QSFP+ 40G', speed_gbps: 40, form_factor: 'QSFP+', obs: 217, avg_price: 180.17 },
{ label: 'QSFP-DD 400G', speed_gbps: 400, form_factor: 'QSFP-DD', obs: 193, avg_price: 510.99 }, { label: 'SFP28 25G', speed_gbps: 25, form_factor: 'SFP28', obs: 198, avg_price: 142.50 },
{ label: 'OSFP 800G', speed_gbps: 800, form_factor: 'OSFP', obs: 80, avg_price: 810.06 }, { label: 'QSFP-DD 400G', speed_gbps: 400, form_factor: 'QSFP-DD', obs: 193, avg_price: 510.99 },
{ label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 } { label: 'OSFP 800G', speed_gbps: 800, form_factor: 'OSFP', obs: 80, avg_price: 810.06 },
{ label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 }
]; ];
var items = seedData; var items = seedFallback;
// Try real price observation counts per form_factor+speed_gbps (live DB data)
try { try {
var d = await api('/api/procurement/signals?limit=30'); var priceCountRes = await api('/api/procurement/signals?limit=200');
if (d && d.signals && d.signals.length > 0) { var sigItems = priceCountRes.data || [];
d.signals.forEach(function(sig) { if (sigItems.length > 0) {
var key = (sig.form_factor || '') + String(sig.speed_gbps || ''); // Build a map from form_factor+speed_gbps → {obs, avg_price}
var ex = items.find(function(it) { return (it.form_factor || '') + String(it.speed_gbps || '') === key; }); var realMap = {};
if (ex && sig.observation_count > 0) ex.obs = sig.observation_count; sigItems.forEach(function(row) {
var key = (row.form_factor || '') + '|' + String(row.speed_gbps || '');
if (!realMap[key] || row.observation_count > realMap[key].obs) {
realMap[key] = { obs: row.observation_count || 0, avg_price: parseFloat(row.avg_price) || 0 };
}
}); });
// Merge into items
items = items.map(function(it) {
var key = (it.form_factor || '') + '|' + String(it.speed_gbps || '');
if (realMap[key] && realMap[key].obs > 0) {
return Object.assign({}, it, { obs: realMap[key].obs, avg_price: realMap[key].avg_price || it.avg_price });
}
return it;
});
if (meta) meta.setAttribute('data-source', 'live');
} }
} catch(e) {} } catch(e) {}
@ -6769,7 +6854,7 @@ var procAiClustersData = [];
var procAiClustersMinTx = 0; var procAiClustersMinTx = 0;
function showProcSection(name) { function showProcSection(name) {
['signals','abc','demand','ai-clusters','market','lifecycle'].forEach(function(s) { ['signals','abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
var sec = el('proc-section-' + s); var sec = el('proc-section-' + s);
var btn = el('proc-btn-' + s); var btn = el('proc-btn-' + s);
if (sec) sec.style.display = s === name ? '' : 'none'; if (sec) sec.style.display = s === name ? '' : 'none';
@ -6778,6 +6863,7 @@ function showProcSection(name) {
// Lazy-load on first visit // Lazy-load on first visit
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand(); if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters(); if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
if (name === 'marketplace' && !el('proc-marketplace-grid').querySelector('.card')) loadProcMarketplace();
} }
async function loadProcurement() { async function loadProcurement() {
@ -7144,6 +7230,167 @@ function filterAiClusters(minTx) {
}).join(''); }).join('');
} }
// ─── MARKET SIGNALS — HYPE CYCLE ENRICHMENT ──────────────────────────────────
function renderHypeMarketContext(ctx, technologies) {
var el2 = el('hype-market-context');
if (!el2) return;
var capexYoy = ctx.capexYoyAvg || 0;
var capexColor = capexYoy > 80 ? '#16a34a' : capexYoy > 40 ? '#ca8a04' : '#64748b';
var totalAiTx = ctx.totalAiClusterTx90d || 0;
var fastTrend = ctx.internalFastMoverTrend || 0;
// Summary stat cards
var cards = [
statCard('💰', capexYoy.toFixed(0) + '% YoY avg', 'Hyperscaler CapEx',
(ctx.capexBoom ? '🚀 Boom — above +50% threshold' : 'Moderate growth') + ' (' + (ctx.hyperscalerCapex || []).map(function(c) { return c.company; }).join(', ') + ')'),
statCard('🤖', totalAiTx > 0 ? '~' + (totalAiTx / 1000).toFixed(0) + 'k Tx' : '—', 'AI Cluster Demand (90d)',
ctx.aiClusterCount90d + ' cluster builds with transceiver demand estimates'),
statCard('📈', fastTrend > 0 ? '+' + fastTrend.toFixed(1) + '%' : fastTrend.toFixed(1) + '%', 'Internal Fast-Mover Trend',
'Average demand trend across ' + (ctx.hyperscalerCapex || []).length + ' fast-moving SKUs'),
];
// Per-technology recommendation summary
var recCards = (technologies || []).map(function(ms) {
if (!ms.recommendation) return '';
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.6rem 0.75rem;min-width:160px">'
+ '<div style="font-size:0.75rem;font-weight:700;color:var(--text)">' + esc(ms.technology) + '</div>'
+ '<div style="font-size:0.72rem;font-weight:700;color:' + ms.recommendation.color + ';margin-top:0.2rem">' + esc(ms.recommendation.label) + '</div>'
+ '<div style="font-size:0.65rem;color:var(--text-dim);margin-top:0.15rem;line-height:1.4">' + esc(ms.recommendation.detail.substring(0, 80)) + (ms.recommendation.detail.length > 80 ? '…' : '') + '</div>'
+ '<div style="margin-top:0.4rem;display:flex;align-items:center;gap:0.4rem">'
+ '<div style="flex:1;height:3px;background:var(--border);border-radius:2px"><div style="width:' + ms.marketSignalScore + '%;height:100%;background:' + ms.recommendation.color + ';border-radius:2px"></div></div>'
+ '<span style="font-size:0.65rem;font-family:monospace;font-weight:700;color:' + ms.recommendation.color + '">' + ms.marketSignalScore + '</span>'
+ '</div></div>';
}).join('');
el2.innerHTML = '<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem">' + cards.join('') + '</div>'
+ '<div style="font-size:0.72rem;font-weight:700;color:var(--text-dim);margin-bottom:0.5rem;letter-spacing:0.05em;text-transform:uppercase">Recommendations per Technology</div>'
+ '<div style="display:flex;gap:0.75rem;flex-wrap:wrap">' + recCards + '</div>';
}
// ─── CAPEX TABLE — in Hype Cycle tab ─────────────────────────────────────────
async function loadHypeCapexAndEbay() {
try {
var d = await api('/api/procurement/hyperscaler-capex');
renderCapexTable(d.data || [], d.summary || []);
} catch(e) {
var el2 = el('capex-table-container');
if (el2) el2.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">Could not load capex data.</div>';
}
try {
var e = await api('/api/procurement/marketplace-velocity');
renderEbayVelocity(e.hot || e.data || []);
renderProcMarketplace(e.hot || e.data || []);
} catch(e2) {}
}
function renderCapexTable(rows, summary) {
var badge = el('capex-avg-badge');
if (badge && summary.length) {
var avgYoy = summary.reduce(function(s, r) { return s + (parseFloat(r.latest_yoy_growth) || 0); }, 0) / summary.length;
badge.textContent = 'Avg YoY: +' + avgYoy.toFixed(0) + '% · ' + summary.length + ' hyperscalers';
badge.style.color = avgYoy > 80 ? '#16a34a' : avgYoy > 40 ? '#ca8a04' : '#2563eb';
badge.style.background = avgYoy > 80 ? '#16a34a11' : '#2563eb11';
badge.style.borderColor = avgYoy > 80 ? '#16a34a33' : '#2563eb33';
}
var container = el('capex-table-container');
if (!container || !rows.length) return;
var html = '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:0.8rem">'
+ '<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">'
+ '<th style="text-align:left;padding:8px 6px">Company</th>'
+ '<th style="text-align:left;padding:8px 6px">Period</th>'
+ '<th class="tip" data-tip="Total quarterly CapEx in millions USD (all infra)" style="text-align:right;padding:8px 6px">CapEx ($M)</th>'
+ '<th class="tip" data-tip="Estimated DC portion (≈55% of total CapEx based on analyst estimates)" style="text-align:right;padding:8px 6px">DC Est. ($M)</th>'
+ '<th class="tip" data-tip="Year-over-year growth of total CapEx vs same quarter prior year" style="text-align:right;padding:8px 6px">YoY %</th>'
+ '<th style="text-align:left;padding:8px 6px">Filing</th>'
+ '</tr></thead><tbody>';
rows.forEach(function(r) {
var yoy = parseFloat(r.yoy_growth_pct);
var yoyColor = yoy > 80 ? '#16a34a' : yoy > 40 ? '#ca8a04' : yoy > 0 ? '#2563eb' : '#ef4444';
var yoyStr = isNaN(yoy) ? '—' : (yoy > 0 ? '+' : '') + yoy.toFixed(1) + '%';
html += '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:7px 6px;font-weight:700;text-transform:capitalize">' + esc(r.company) + '</td>'
+ '<td style="padding:7px 6px;color:var(--text-dim)">' + esc(r.period_label) + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;font-weight:600">$' + parseFloat(r.capex_usd_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;color:var(--text-dim)">$' + parseFloat(r.dc_capex_est_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + yoyColor + '">' + yoyStr + '</td>'
+ '<td style="padding:7px 6px;font-size:0.68rem;color:var(--text-dim)">' + esc(r.filing_type || '—') + '</td>'
+ '</tr>';
});
container.innerHTML = html + '</tbody></table></div>';
}
function renderEbayVelocity(rows) {
var container = el('ebay-velocity-container');
if (!container || !rows.length) {
if (container) container.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">No marketplace data.</div>';
return;
}
var html = '<div style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))">';
rows.forEach(function(r) {
var sold = parseInt(r.sold_count_30d) || 0;
var listings = parseInt(r.active_listings) || 0;
var sellThrough = listings > 0 ? ((sold / listings) * 100).toFixed(1) + '%' : '—';
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
var avgPrice = parseFloat(r.avg_sold_price);
html += '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.8rem">'
+ '<div style="font-size:0.82rem;font-weight:700">' + esc(r.form_factor || r.keyword || '?') + ' ' + esc(r.speed_label || '') + '</div>'
+ '<div style="display:flex;align-items:baseline;gap:0.4rem;margin-top:0.4rem">'
+ '<span style="font-size:1.5rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
+ '<span style="font-size:0.72rem;color:var(--text-dim)">sold / 30d</span>'
+ '</div>'
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem">'
+ listings.toLocaleString() + ' active listings · sell-through ' + sellThrough
+ '</div>'
+ (avgPrice > 0 ? '<div style="font-size:0.72rem;color:var(--text-dim)">Avg sold: $' + avgPrice.toLocaleString(undefined,{maximumFractionDigits:0}) + '</div>' : '')
+ '</div>';
});
container.innerHTML = html + '</div>';
}
// ─── PROC MARKETPLACE SECTION ─────────────────────────────────────────────────
async function loadProcMarketplace() {
var grid = el('proc-marketplace-grid');
try {
var d = await api('/api/procurement/marketplace-velocity');
renderProcMarketplace(d.hot || d.data || []);
} catch(e) {
if (grid) grid.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Could not load marketplace data.</div>';
}
}
function renderProcMarketplace(rows) {
var grid = el('proc-marketplace-grid');
if (!grid) return;
if (!rows.length) { grid.innerHTML = '<div style="color:var(--text-dim)">No data yet.</div>'; return; }
grid.innerHTML = rows.map(function(r) {
var sold = parseInt(r.sold_count_30d) || 0;
var listings = parseInt(r.active_listings) || 0;
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
var borderColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : 'var(--border)';
var avgP = parseFloat(r.avg_sold_price);
return '<div class="card" style="border-left:3px solid ' + borderColor + '">'
+ '<div style="font-weight:700;font-size:0.9rem">' + esc(r.form_factor || '?') + ' ' + esc(r.speed_label || '') + '</div>'
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">' + esc(r.keyword || '') + ' · ' + esc(r.marketplace || 'eBay') + '</div>'
+ '<div style="display:flex;align-items:baseline;gap:0.5rem">'
+ '<span style="font-size:1.8rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
+ '<span style="font-size:0.75rem;color:var(--text-dim)">sold / 30d</span>'
+ '</div>'
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem">'
+ listings.toLocaleString() + ' active listings'
+ (avgP > 0 ? ' · avg $' + avgP.toLocaleString(undefined,{maximumFractionDigits:0}) : '')
+ '</div>'
+ '</div>';
}).join('');
}
// INIT // INIT
loadOverview(); loadOverview();
loadChangelog(); loadChangelog();