Explorar el Código

Add_Relative_Volume-Weighted_Percentile_Ranking

Replaced single absolute percentile calculations with a dual absolute/relative formatting string `(Relative% / Absolute%)` to capture cash-flow market weight.

        PURPOSE:
        To protect users from falsely prioritizing "statistically excellent" base builds that operate in tiny, illiquid markets. Calculating a relative percentile weighted by the maximum potential cash flow of a recipe isolates the highly profitable, massively traded commodities from the niche, low-volume anomalies. Displaying both metrics simultaneously provides a holistic strategic assessment: an `(80.0% / 99.0%)` rank immediately informs the player that the recipe is top 1% mechanically, but only top 20% when scaled against global market liquidity.

        IMPLEMENTATION DETAILS:
        - `ts/roi.ts`: Added `market_cash_flow = revenue_val * p.market_capacity_base` to the `.map()` derivation block. This defines the weight limit, dynamically computing the total global daily market capitalization for the recipe's bottleneck item.
        - `ts/roi.ts`: Modified all 6 structural mapping arrays (e.g. `arrProfit`, `arrCapex`) to store object tuples `{val: number, weight: number}` rather than isolated numeric primitives.
        - `ts/roi.ts`: Redesigned `getPercentiles()` to simultaneously calculate `absDecimal` based on unweighted item count and `relDecimal` based on the sum of `market_cash_flow` weights below the target row. Designed a max-denominator mapping system to ensure that the highest strict array item cleanly normalizes to `100.0%` rather than stalling at `99.9%`.
        - `ts/roi.ts`: Updated the standard header tooltips to inject an explicit legend clarifying the `(Relative Volume / Absolute)` structure and explaining the market cash flow weighting parameters to prevent initial user confusion.
Thomas Knott hace 1 semana
padre
commit
31d261a677
Se han modificado 1 ficheros con 75 adiciones y 39 borrados
  1. 75 39
      ts/roi.ts

+ 75 - 39
ts/roi.ts

@@ -56,8 +56,6 @@ let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as Metri
 let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
 let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
 
-// EXTREME DETAIL: Because '0' is no longer an option in the UI, we update the fallback
-// state to 'omit' to prevent the script from trying to load a non-existent dropdown item.
 let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit';
 let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
 let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
@@ -75,9 +73,6 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
-		// EXTREME DETAIL: We generate the massive dropdown arrays on the fly using ES6 interpolation.
-		// Array.from({length: x}) creates an empty array of size x, and the mapping function evaluates
-		// the index (i) to inject the correct incremental HTML options directly into the template string.
 		controls.innerHTML = `
 			<label style="margin-right: 15px;">CapEx Price: 
 				<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
@@ -171,6 +166,8 @@ async function render() {
 			'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
 		];
 		
+		const pctExplainer = '\n(Percentiles: Relative Volume / Absolute. 100.0% is the most desirable outcome. Relative rank is weighted by total market cash flow.)';
+
 		ths.forEach((th, i) => {
 			if (keys[i]) {
 				th.style.cursor = 'pointer';
@@ -178,21 +175,21 @@ async function render() {
 				
 				if (keys[i] === 'profit_per_base') {
 					th.textContent = 'Profit/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 100.0% as the most desirable outcome, i.e., highest profit or lowest cost.)';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer;
 				} else if (keys[i] === 'capex_val') {
 					th.textContent = 'CapEx/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, and optional Working Capital, HQ Upgrades, and Ship CapEx (use "Omit From Calculation" to exclude).';
+					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, and optional Working Capital, HQ Upgrades, and Ship CapEx (use "Omit From Calculation" to exclude).' + pctExplainer;
 				} else if (keys[i] === 'opex_val') {
 					th.textContent = 'OpEx/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.' + pctExplainer;
 				} else if (keys[i] === 'normalized_logistics_per_base') {
 					th.textContent = 'Logistics/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nDaily logistics bottleneck scaled to a full 500-area planetary base. The suffix indicates whether Weight (t) or Volume (m³) of Inputs (I) or Outputs (O) is the limiting bottleneck.\nSorts and percentiles are strictly normalized based on ship capacity limits (3000t or 1000m³).';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily logistics bottleneck scaled to a full 500-area planetary base. The suffix indicates whether Weight (t) or Volume (m³) of Inputs (I) or Outputs (O) is the limiting bottleneck.\nSorts and percentiles are strictly normalized based on ship capacity limits (3000t or 1000m³).' + pctExplainer;
 				} else if (keys[i] === 'market_capacity_base') {
 					th.textContent = 'Market Cap (Bases)';
-					th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.';
+					th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.' + pctExplainer;
 				} else if (keys[i] === 'break_even') {
-					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes optional logistics and HQ upgrades to accurately reflect operational readiness.';
+					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes optional logistics and HQ upgrades to accurately reflect operational readiness.' + pctExplainer;
 				} else {
 					th.dataset.tooltip = 'Click to sort.';
 				}
@@ -286,6 +283,11 @@ async function render() {
 		const profit_per_base = revenue_val - opex_val;
 		const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
 		
+		// EXTREME DETAIL: We determine the specific market weight of this recipe line.
+		// Multiplying the revenue (value per day per base) by the market capacity (max bases allowed)
+		// yields the total cash flow value of all available trades in the global FIO market for this bottleneck.
+		const market_cash_flow = revenue_val * p.market_capacity_base;
+		
 		return { 
 			...p, 
 			capex_val, 
@@ -296,7 +298,8 @@ async function render() {
 			hq_capex,
 			activeWorkingCapitalDays,
 			activeShipCapex,
-			shipsNeeded
+			shipsNeeded,
+			market_cash_flow
 		};
 	});
 
@@ -304,13 +307,14 @@ async function render() {
 		profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
 	}
 
-	const numSort = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
-	const arrProfit = profitsWithMetrics.map(p => p.profit_per_base).sort(numSort);
-	const arrBreak = profitsWithMetrics.map(p => p.break_even).sort(numSort);
-	const arrCapex = profitsWithMetrics.map(p => p.capex_val).sort(numSort);
-	const arrOpex = profitsWithMetrics.map(p => p.opex_val).sort(numSort);
-	const arrLog = profitsWithMetrics.map(p => p.normalized_logistics_per_base).sort(numSort);
-	const arrCap = profitsWithMetrics.map(p => p.market_capacity_base).sort(numSort);
+	// EXTREME DETAIL: Overhauled the extraction arrays to store both the numerical value AND the weight parameter.
+	const numSortObj = (a: {val: number, weight: number}, b: {val: number, weight: number}) => (a.val < b.val ? -1 : a.val > b.val ? 1 : 0);
+	const arrProfit = profitsWithMetrics.map(p => ({val: p.profit_per_base, weight: p.market_cash_flow})).sort(numSortObj);
+	const arrBreak = profitsWithMetrics.map(p => ({val: p.break_even, weight: p.market_cash_flow})).sort(numSortObj);
+	const arrCapex = profitsWithMetrics.map(p => ({val: p.capex_val, weight: p.market_cash_flow})).sort(numSortObj);
+	const arrOpex = profitsWithMetrics.map(p => ({val: p.opex_val, weight: p.market_cash_flow})).sort(numSortObj);
+	const arrLog = profitsWithMetrics.map(p => ({val: p.normalized_logistics_per_base, weight: p.market_cash_flow})).sort(numSortObj);
+	const arrCap = profitsWithMetrics.map(p => ({val: p.market_capacity_base, weight: p.market_cash_flow})).sort(numSortObj);
 
 	profitsWithMetrics.sort((a, b) => {
 		let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
@@ -329,22 +333,24 @@ async function render() {
 	for (const p of profitsWithMetrics) {
 		const tr = document.createElement('tr');
 		
-		const pctProfit = getPercentile(p.profit_per_base, arrProfit, false);
-		const pctBreak = getPercentile(p.break_even, arrBreak, true);
-		const pctCapex = getPercentile(p.capex_val, arrCapex, true);
-		const pctOpex = getPercentile(p.opex_val, arrOpex, true);
-		const pctLog = getPercentile(p.normalized_logistics_per_base, arrLog, true);
-		const pctCap = getPercentile(p.market_capacity_base, arrCap, false);
+		// Map the raw value to both absolute and relative percentile ranks.
+		const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false);
+		const pctBreak = getPercentiles(p.break_even, arrBreak, true);
+		const pctCapex = getPercentiles(p.capex_val, arrCapex, true);
+		const pctOpex = getPercentiles(p.opex_val, arrOpex, true);
+		const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true);
+		const pctCap = getPercentiles(p.market_capacity_base, arrCap, false);
 		
+		// Interplate the Rel/Abs format string requested directly into the <small> span wrapper.
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
-			<td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctProfit})</span></td>
-			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d <span style="font-size: 0.85em; opacity: 0.6;">(${pctBreak})</span></td>
-			<td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCapex})</span></td>
-			<td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctOpex})</span></td>
-			<td style="color: ${color(p.normalized_logistics_per_base, 1.0, 0.1)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck} <span style="font-size: 0.85em; opacity: 0.6;">(${pctLog})</span></td>
-			<td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCap})</span></td>
+			<td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctProfit.rel} / ${pctProfit.abs})</span></td>
+			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d <span style="font-size: 0.85em; opacity: 0.6;">(${pctBreak.rel} / ${pctBreak.abs})</span></td>
+			<td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCapex.rel} / ${pctCapex.abs})</span></td>
+			<td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctOpex.rel} / ${pctOpex.abs})</span></td>
+			<td style="color: ${color(p.normalized_logistics_per_base, 1.0, 0.1)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck} <span style="font-size: 0.85em; opacity: 0.6;">(${pctLog.rel} / ${pctLog.abs})</span></td>
+			<td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCap.rel} / ${pctCap.abs})</span></td>
 		`;
 
 		const output = tr.querySelector('td')!;
@@ -384,20 +390,49 @@ async function render() {
 	saveState();
 }
 
-function getPercentile(val: number, sortedArr: number[], invert: boolean = false): string {
-	if (sortedArr.length < 2) return "100.0%";
-	let less = 0;
+// EXTREME DETAIL: Overhauled function to calculate both Absolute and Relative Volume-Weighted Percentiles.
+// To ensure the mathematical max bounds cleanly hit 100.0%, the denominator is extracted dynamically 
+// relative to the highest data point present in the array.
+function getPercentiles(val: number, sortedArr: {val: number, weight: number}[], invert: boolean = false): {abs: string, rel: string} {
+	if (sortedArr.length < 2) return {abs: "100.0%", rel: "100.0%"};
+	
+	let lessCount = 0;
+	let lessWeight = 0;
+	
 	for (let i = 0; i < sortedArr.length; i++) {
-		if (sortedArr[i] < val) less++;
-		else break; 
+		if (sortedArr[i].val < val) {
+			lessCount++;
+			lessWeight += sortedArr[i].weight;
+		} else {
+			break; 
+		}
 	}
 	
-	let decimal = less / (sortedArr.length - 1);
+	const maxVal = sortedArr[sortedArr.length - 1].val;
+	let maxLessCount = 0;
+	let maxLessWeight = 0;
+	
+	for (let i = 0; i < sortedArr.length; i++) {
+		if (sortedArr[i].val < maxVal) {
+			maxLessCount++;
+			maxLessWeight += sortedArr[i].weight;
+		} else {
+			break;
+		}
+	}
+
+	let absDecimal = maxLessCount > 0 ? lessCount / maxLessCount : 1.0;
+	let relDecimal = maxLessWeight > 0 ? lessWeight / maxLessWeight : 1.0;
+
 	if (invert) {
-		decimal = 1.0 - decimal;
+		absDecimal = 1.0 - absDecimal;
+		relDecimal = 1.0 - relDecimal;
 	}
 	
-	return (decimal * 100).toFixed(1) + "%";
+	return {
+		abs: (absDecimal * 100).toFixed(1) + "%",
+		rel: (relDecimal * 100).toFixed(1) + "%"
+	};
 }
 
 function saveState() {
@@ -473,6 +508,7 @@ interface ProfitWithMetrics extends Profit {
 	activeWorkingCapitalDays: number; 
 	activeShipCapex: number; 
 	shipsNeeded: number; 
+	market_cash_flow: number; // Exported weight tracker
 }
 
 interface MatPrice {