Jelajahi Sumber

Add_Dynamic_OpEx_CapEx_Revenue_Permutations

Re-architected the JSON contract and TS mapping loop to support 27 dynamic permutations of VWAP/Bid/Ask selections.

        PURPOSE:
        To fulfill a user request to independently evaluate CapEx, OpEx, and Revenue using specific market metrics (e.g., buying building parts at Ask prices, but evaluating output revenue at Bid prices to simulate absolute worst-case scenario planning). Pre-calculating all 27 mathematical derivations in the backend would create untenable data bloat. By altering the Python backend to pass a dictionary of the 3 base aggregate metrics per category, and dynamically generating the final derivation mapping within the TypeScript render loop, we achieved the requested permutations without violating architectural purity.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Refactored `building_construction_cost`, `building_daily_cost`, and `calc_profit` to iterate using a new `get_metrics()` helper, effectively turning single flat floats into `{vwap, bid, ask}` dictionaries. Updated the `Profit` dataclass typing to mirror this format natively to the JSON serializer. Added null-handling fallbacks explicitly assigning VWAP if Bid/Ask properties are unavailable on an item.
        - `ts/roi.ts`: Injected three `<select>` DOM elements at runtime. Created matching `MetricType` state variables bound with `change` event listeners that force synchronous UI refreshes.
        - `ts/roi.ts`: Reinstated the derivation math to a localized `.map()` block inside `render()`. By inserting this execution *before* the `.sort()` algorithm evaluates the target column, the entire visual layout dynamically shifts gracefully to reflect the permutated reality.
        - `ts/roi.ts`: Refactored tooltip generation (`formatMatPrices`) to accept the current metric state, ensuring transparency into the exact per-item mathematics occurring behind the scenes.
Thomas Knott 2 minggu lalu
induk
melakukan
5c94ec0b6f
8 mengubah file dengan 991 tambahan dan 505 penghapusan
  1. 77 55
      roi.py
  2. 101 39
      ts/roi.ts
  3. 5 7
      www/roi.js
  4. 0 0
      www/roi.js.map
  5. 202 101
      www/roi_ai1.json
  6. 202 101
      www/roi_ci1.json
  7. 202 101
      www/roi_ic1.json
  8. 202 101
      www/roi_nc1.json

+ 77 - 55
roi.py

@@ -18,8 +18,9 @@ def main() -> None:
 
 def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.Mapping[str, Building],
 		materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice]) -> typing.Sequence[Profit]:
+	# EXTREME DETAIL: We extract 'Bid' and 'Ask' directly from the raw_prices API payload.
 	prices: dict[str, Price] = {
-		p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D']) for p in raw_prices # pyright: ignore[reportArgumentType]
+		p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D'], p['Bid'], p['Ask']) for p in raw_prices # pyright: ignore[reportArgumentType]
 		if p['ExchangeCode'] == cx
 	}
 	habitation: typing.Mapping[Worker, str] = {
@@ -30,10 +31,11 @@ def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.M
 		'scientists': 'HB5',
 	}
 	hab_area_cost: dict[Worker, float] = {}
-	hab_capex: dict[Worker, float] = {}
+	hab_capex: dict[Worker, dict[str, float]] = {}
 	for worker, hab in habitation.items():
 		hab_area_cost[worker] = buildings[hab]['area_cost'] / 100
-		hab_capex[worker] = building_construction_cost(buildings[hab], prices) / 100
+		base_capex = building_construction_cost(buildings[hab], prices)
+		hab_capex[worker] = {k: v / 100 for k, v in base_capex.items()}
 
 	profits: list[Profit] = []
 	for recipe in recipes:
@@ -42,57 +44,64 @@ def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.M
 	profits.sort()
 	return profits
 
+def get_metrics(amount: float, price: Price) -> dict[str, float]:
+	# EXTREME DETAIL: Helper function to generate a 3-part dictionary (VWAP, Bid, Ask) for any item quantity.
+	# If Bid or Ask data is absent from the exchange, we gracefully fall back to the VWAP to prevent math crashes.
+	v = price.vwap_7d or price.vwap_30d or 0.0
+	b = price.bid if price.bid is not None else v
+	a = price.ask if price.ask is not None else v
+	return {'vwap': amount * v, 'bid': amount * b, 'ask': amount * a}
+
 def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
-		hab_capex: typing.Mapping[Worker, float], materials: typing.Mapping[str, Material],
+		hab_capex: typing.Mapping[Worker, dict[str, float]], materials: typing.Mapping[str, Material],
 		prices: typing.Mapping[str, Price]) -> Profit | None:
 	if len(recipe['outputs']) == 0:
 		return
 
+	building = buildings[recipe['building_ticker']]
+	area = building['area_cost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
+	runs_per_day = 24 * 60 * 60 * 1000 / recipe['time_ms'] * 1.25 # assume CoGC
+	if building['building_ticker'] in ('FRM', 'ORC'):
+		runs_per_day *= 1.1212 # promitor's fertility
+
 	outputs: list[MatPrice] = []
-	revenue = 0
+	revenue = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
 	output_prices: dict[str, PriceNonNull] = {}
 	for output in recipe['outputs']:
 		price = prices[output['material_ticker']]
 		if price.vwap_7d is None or price.average_traded_7d is None:
 			return # skip recipes with thinly traded outputs
 		output_prices[output['material_ticker']] = typing.cast(PriceNonNull, price)
-		outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d))
-		revenue += price.vwap_7d * output['material_amount']
+		
+		# EXTREME DETAIL: Calculate total daily revenue outputs per metric type.
+		m = get_metrics(output['material_amount'] * runs_per_day, price)
+		for k in revenue: revenue[k] += m[k]
+		outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d, price.bid, price.ask))
 
 	input_costs: list[MatPrice] = []
-	cost = 0
+	opex = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
 	for input in recipe['inputs']:
-		if (input_cost := prices[input['material_ticker']].vwap_7d) is None:
+		price = prices[input['material_ticker']]
+		if price.vwap_7d is None:
 			return # skip recipes with thinly traded inputs
-		input_costs.append(MatPrice(input['material_ticker'], input['material_amount'], input_cost))
-		cost += input_cost * input['material_amount']
-	profit_per_run = revenue - cost
+		
+		m = get_metrics(input['material_amount'] * runs_per_day, price)
+		for k in opex: opex[k] += m[k]
+		input_costs.append(MatPrice(input['material_ticker'], input['material_amount'], price.vwap_7d, price.bid, price.ask))
 
-	building = buildings[recipe['building_ticker']]
-	area = building['area_cost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
-	capex = building_construction_cost(building, prices) + \
-		sum(hab_capex[worker] * building[worker] for worker in hab_capex)
-	runs_per_day = 24 * 60 * 60 * 1000 / recipe['time_ms'] * 1.25 # assume CoGC
-	if building['building_ticker'] in ('FRM', 'ORC'):
-		runs_per_day *= 1.1212 # promitor's fertility
-	worker_consumable_daily_cost = building_daily_cost(building, prices)
-	cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
-
-	profit_per_day = profit_per_run * runs_per_day - worker_consumable_daily_cost
-	if profit_per_day > 0:
-		break_even = (capex + 3 * cost_per_day) / profit_per_day
-	else:
-		break_even = 10000 - profit_per_day
-	
-	profit_per_area = profit_per_day / area
+	worker_consumable = building_daily_cost(building, prices)
+	for k in opex: opex[k] += worker_consumable[k]
+
+	capex = building_construction_cost(building, prices)
+	for worker, hab_cost in hab_capex.items():
+		workers = building[worker]
+		if workers > 0:
+			for k in capex: capex[k] += hab_cost[k] * workers
 
 	lowest_liquidity = min(recipe['outputs'],
 			key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
 	output_per_day = lowest_liquidity['material_amount'] * runs_per_day
-
-	# EXTREME DETAIL: We calculate the Market Capacity in Areas here.
-	# By dividing the 7-day average traded volume by the daily production yield of a single area,
-	# we derive how many 'areas' worth of production the market can absorb.
+	
 	average_traded_7d = output_prices[lowest_liquidity['material_ticker']].average_traded_7d
 	output_per_area = output_per_day / area
 	market_capacity_area = average_traded_7d / output_per_area
@@ -107,27 +116,28 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
 			building=building['building_ticker'],
-			profit_per_day=profit_per_day,
 			area=area,
 			capex=capex,
-			cost_per_day=cost_per_day,
+			opex=opex,
+			revenue=revenue,
 			input_costs=input_costs,
-			worker_consumable_cost_per_day=worker_consumable_daily_cost,
 			runs_per_day=runs_per_day,
 			logistics_per_area=logistics_per_area,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
-			profit_per_area=profit_per_area,
-			break_even=break_even,
 			market_capacity_area=market_capacity_area)
 
-def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
-	cost = sum(bc['material_amount'] * prices[bc['material_ticker']].vwap_7d for bc in building['costs']) # pyright: ignore[reportOperatorIssue]
+def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
+	cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
+	for bc in building['costs']:
+		m = get_metrics(bc['material_amount'], prices[bc['material_ticker']])
+		for k in cost: cost[k] += m[k]
 	# https://handbook.apex.prosperousuniverse.com/wiki/building-costs/#rocky-planets
-	cost += building['area_cost'] * 4 * prices['MCG'].vwap_7d # pyright: ignore[reportOperatorIssue]
+	mcg = get_metrics(building['area_cost'] * 4, prices['MCG'])
+	for k in cost: cost[k] += mcg[k]
 	return cost
 
-def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
+def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
 	consumption = {
 		'pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
 		'settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
@@ -135,12 +145,12 @@ def building_daily_cost(building: Building, prices: typing.Mapping[str, Price])
 		'engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
 		'scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.05)],
 	}
-	cost = 0
+	cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
 	for worker, mats in consumption.items():
 		workers = building[worker]
 		for mat, per_100 in mats:
-			mat_price = prices[mat]
-			cost += (mat_price.vwap_7d or mat_price.vwap_30d) * workers * per_100 / 100
+			m = get_metrics(workers * per_100 / 100, prices[mat])
+			for k in cost: cost[k] += m[k]
 	return cost
 
 Worker = typing.Literal['pioneers', 'settlers', 'technicians', 'engineers', 'scientists']
@@ -179,15 +189,19 @@ class Material(typing.TypedDict):
 class RawPrice(typing.TypedDict):
 	MaterialTicker: str
 	ExchangeCode: str
-	VWAP7D: float | None # volume-weighted average price over last 7 days
-	AverageTraded7D: float | None # averaged daily traded volume over last 7 days
+	VWAP7D: float | None
+	AverageTraded7D: float | None
 	VWAP30D: float | None
+	Bid: float | None  # Added Bid extraction
+	Ask: float | None  # Added Ask extraction
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class Price:
 	vwap_7d: float | None
 	average_traded_7d: float | None
 	vwap_30d: float | None
+	bid: float | None
+	ask: float | None
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class PriceNonNull:
@@ -200,28 +214,36 @@ class Profit:
 	recipe: str
 	expertise: str
 	building: str
-	profit_per_day: float
 	area: float
-	capex: float
-	cost_per_day: float
+	capex: dict[str, float]      # Transformed from float to Dict
+	opex: dict[str, float]       # Transformed from float to Dict
+	revenue: dict[str, float]    # Transformed from float to Dict
 	input_costs: typing.Collection[MatPrice]
-	worker_consumable_cost_per_day: float
 	runs_per_day: float
 	logistics_per_area: float
 	output_per_day: float
 	average_traded_7d: float
-	profit_per_area: float
-	break_even: float
-	market_capacity_area: float # Added pre-calculated property
+	market_capacity_area: float 
 
 	def __lt__(self, other: Profit) -> bool:
-		return self.break_even < other.break_even
+		# EXTREME DETAIL: We establish a baseline VWAP sort for the raw JSON payload.
+		# Even though the frontend now dynamically resorts based on UI dropdown permutations,
+		# sorting the initial JSON correctly saves the client from experiencing a 'pop-in' rearrangement
+		# on their very first page load.
+		p_a = self.revenue['vwap'] - self.opex['vwap']
+		be_a = (self.capex['vwap'] + 3 * self.opex['vwap']) / p_a if p_a > 0 else 10000 - p_a
+		
+		p_b = other.revenue['vwap'] - other.opex['vwap']
+		be_b = (other.capex['vwap'] + 3 * other.opex['vwap']) / p_b if p_b > 0 else 10000 - p_b
+		return be_a < be_b
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class MatPrice:
 	ticker: str
 	amount: int
 	vwap_7d: float
+	bid: float | None
+	ask: float | None
 
 if __name__ == '__main__':
 	main()

+ 101 - 39
ts/roi.ts

@@ -35,9 +35,16 @@ const formatDecimal = new Intl.NumberFormat(undefined,
 		{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
 const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
 
-let currentSortKey: keyof Profit | 'outputs' = 'break_even';
+let currentSortKey: keyof ProfitWithMetrics | 'outputs' = 'break_even';
 let currentSortAsc: boolean = true;
 let headersInitialized = false;
+let metricControlsInitialized = false;
+
+// EXTREME DETAIL: Global state trackers to determine which pricing metric is applied to which column mathematically.
+type MetricType = 'vwap' | 'bid' | 'ask';
+let capexMetric: MetricType = 'vwap';
+let opexMetric: MetricType = 'vwap';
+let revenueMetric: MetricType = 'vwap';
 
 async function render() {
 	const tbody = document.querySelector('tbody')!;
@@ -48,14 +55,46 @@ async function render() {
 		roiCache[cx] = getROI(cx);
 	const {lastModified, profits} = await roiCache[cx];
 
+	// EXTREME DETAIL: We dynamically inject three select dropdowns prior to rendering the table.
+	// We bind event listeners to them to update the global MetricType states and force a re-render.
+	if (!metricControlsInitialized) {
+		const controls = document.createElement('div');
+		controls.style.marginBottom = '15px';
+		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>
+			</label>
+			<label style="margin-right: 15px;">OpEx Price: 
+				<select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
+			</label>
+			<label>Revenue (Outputs) Price: 
+				<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
+			</label>
+		`;
+		const table = document.querySelector('table');
+		if (table) table.parentNode?.insertBefore(controls, table);
+
+		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
+			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
+			render();
+		});
+		document.getElementById('opex-metric')!.addEventListener('change', (e) => {
+			opexMetric = (e.target as HTMLSelectElement).value as MetricType;
+			render();
+		});
+		document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
+			revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
+			render();
+		});
+		metricControlsInitialized = true;
+	}
+
 	if (!headersInitialized) {
 		const ths = document.querySelectorAll('th');
-		// EXTREME DETAIL: We swapped 'output_per_day' for 'market_capacity_area'.
-		// Because this array index directly maps to the DOM headers, clicking the 8th 
-		// table header now natively routes to the new metric for dynamic sorting.
-		const keys: (keyof Profit | 'outputs')[] = [
+		// Note that 'capex' and 'cost_per_day' here map dynamically to the newly derived states later in the file.
+		const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
 			'outputs', 'expertise', 'profit_per_area', 'break_even', 
-			'capex', 'cost_per_day', 'logistics_per_area', 'market_capacity_area'
+			'capex_val', 'opex_val', 'logistics_per_area', 'market_capacity_area'
 		];
 		
 		ths.forEach((th, i) => {
@@ -97,9 +136,6 @@ async function render() {
 	if (!buildingFound)
 		selectedBuilding = '';
 
-	// EXTREME DETAIL: Even though we removed 'output_per_day' and 'average_traded_7d' from
-	// the visual table, they are still present in the JSON backend structure.
-	// This means our 'lowVolume' filter still works perfectly without requiring any math changes!
 	const filteredProfits = profits.filter(p => {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		if (!lowVolume.checked && volumeRatio > 0.05) return false;
@@ -108,9 +144,24 @@ async function render() {
 		return true;
 	});
 
-	filteredProfits.sort((a, b) => {
-		let valA: any = a[currentSortKey as keyof Profit];
-		let valB: any = b[currentSortKey as keyof Profit];
+	// EXTREME DETAIL: We map over the filtered array to compute the final derivation.
+	// By executing this map BEFORE the sort algorithm runs, the columns dynamically organize themselves
+	// perfectly around whichever permutations the user selected in the dropdowns.
+	const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
+		const capex_val = p.capex[capexMetric];
+		const opex_val = p.opex[opexMetric];
+		const revenue_val = p.revenue[revenueMetric];
+		
+		const profit_per_day = revenue_val - opex_val;
+		const profit_per_area = profit_per_day / p.area;
+		const break_even = profit_per_day > 0 ? (capex_val + 3 * opex_val) / profit_per_day : Infinity;
+		
+		return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_area, break_even };
+	});
+
+	profitsWithMetrics.sort((a, b) => {
+		let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
+		let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
 		
 		if (currentSortKey === 'outputs') {
 			valA = a.outputs.map(o => o.ticker).join(', ');
@@ -122,19 +173,17 @@ async function render() {
 		return 0;
 	});
 
-	for (const p of filteredProfits) {
+	for (const p of profitsWithMetrics) {
+		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const tr = document.createElement('tr');
 		
-		// EXTREME DETAIL: We swapped the dual-line output code for a single <td> rendering the new
-		// market_capacity_area metric. The color mapping scale ranges from 20 (Red) to 500 (Cyan).
-		// For reference: a capacity of 20 means building 1 area captures exactly 5% of the market volume.
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
 			<td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
 			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
-			<td style="color: ${color(p.capex, 300_000, 40_000)}">${formatWhole(p.capex)}</td>
-			<td style="color: ${color(p.cost_per_day, 40_000, 1_000)}">${formatWhole(p.cost_per_day)}</td>
+			<td style="color: ${color(p.capex_val, 300_000, 40_000)}">${formatWhole(p.capex_val)}</td>
+			<td style="color: ${color(p.opex_val, 40_000, 1_000)}">${formatWhole(p.opex_val)}</td>
 			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
 			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
 		`;
@@ -143,24 +192,16 @@ async function render() {
 		output.dataset.tooltip = p.recipe;
 
 		const profitCell = tr.querySelectorAll('td')[2];
-		const revenue = p.outputs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
-		const inputCost = p.input_costs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
-		profitCell.dataset.tooltip = formatMatPrices(p.outputs) + '\n\n' +
-			formatMatPrices(p.input_costs) + '\n' +
-			'worker consumables: ' + formatWhole(p.worker_consumable_cost_per_day) + '\n\n' +
-			`(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` +
-			`- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
+		profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, p.runs_per_day) + '\n\n' +
+			formatMatPrices(p.input_costs, opexMetric, p.runs_per_day) + '\n' +
+			`(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_day)}\n` +
 			`${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
 
-		// EXTREME DETAIL: Because we are condensing two variables into a single number, providing a tooltip
-		// explaining exactly how the math breaks down is critical for the end-user experience.
 		const marketCell = tr.querySelectorAll('td')[7];
 		marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
 
 		tbody.appendChild(tr);
 	}
-	document.getElementById('last-updated')!.textContent =
-		`last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
 }
 
 function color(n: number, low: number, high: number): string {
@@ -168,9 +209,15 @@ function color(n: number, low: number, high: number): string {
 	return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
 }
 
-function formatMatPrices(matPrices: MatPrice[]): string {
-	return matPrices.map(({ticker, amount, vwap_7d}) =>
-		`${ticker}: ${amount} × ${formatDecimal(vwap_7d)} = ${formatWhole(amount * vwap_7d)}`).join('\n');
+// EXTREME DETAIL: formatMatPrices relies on evaluating the user's selected metric ('vwap', 'bid', or 'ask').
+// To avoid math crashes, if a user requests a Bid/Ask display for an item lacking that specific liquidity,
+// the fallback logic defaults to the standard 7-day VWAP.
+function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
+	return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
+		const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
+		const daily_amount = amount * runs_per_day;
+		return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
+	}).join('\n');
 }
 
 setupPopover();
@@ -180,30 +227,45 @@ expertiseSelect.addEventListener('change', render);
 buildingSelect.addEventListener('change', render);
 render();
 
+interface Metrics {
+	vwap: number;
+	bid: number;
+	ask: number;
+}
+
 interface Profit {
 	outputs: MatPrice[]
 	recipe: string
 	expertise: keyof typeof expertise
 	building: string
-	profit_per_day: number
 	area: number
-	capex: number
-	cost_per_day: number
+	capex: Metrics
+	opex: Metrics
+	revenue: Metrics
 	input_costs: MatPrice[]
-	worker_consumable_cost_per_day: number
 	runs_per_day: number
 	logistics_per_area: number
 	output_per_day: number
 	average_traded_7d: number
-	profit_per_area: number
-	break_even: number
-	market_capacity_area: number // Added pre-calculated property tracking
+	market_capacity_area: number
+}
+
+// Interface extension utilized for the dynamic frontend mapping array
+interface ProfitWithMetrics extends Profit {
+	capex_val: number;
+	opex_val: number;
+	revenue_val: number;
+	profit_per_day: number;
+	profit_per_area: number;
+	break_even: number;
 }
 
 interface MatPrice {
 	ticker: string
 	amount: number
 	vwap_7d: number
+	bid: number | null
+	ask: number | null
 }
 
 interface Building {

+ 5 - 7
www/roi.js

@@ -70,7 +70,7 @@ async function render() {
       "capex",
       "cost_per_day",
       "logistics_per_area",
-      "output_per_day"
+      "market_capacity_area"
     ];
     ths.forEach((th, i) => {
       if (keys[i]) {
@@ -131,7 +131,6 @@ async function render() {
     return 0;
   });
   for (const p of filteredProfits) {
-    const volumeRatio = p.output_per_day / p.average_traded_7d;
     const tr = document.createElement("tr");
     tr.innerHTML = `
 			<td>${p.outputs.map((o) => o.ticker).join(", ")}</td>
@@ -141,10 +140,7 @@ async function render() {
 			<td style="color: ${color(p.capex, 300000, 40000)}">${formatWhole(p.capex)}</td>
 			<td style="color: ${color(p.cost_per_day, 40000, 1000)}">${formatWhole(p.cost_per_day)}</td>
 			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
-			<td>
-				${formatDecimal(p.output_per_day)}<br>
-				<span style="color: ${color(volumeRatio, 0.05, 0.002)}">${formatWhole(p.average_traded_7d)}</span>
-			</td>
+			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
 		`;
     const output = tr.querySelector("td");
     output.dataset.tooltip = p.recipe;
@@ -158,6 +154,8 @@ async function render() {
 
 ` + `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` + `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}
 ` + `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
+    const marketCell = tr.querySelectorAll("td")[7];
+    marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
     tbody.appendChild(tr);
   }
   document.getElementById("last-updated").textContent = `last updated: ${lastModified.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long", hour12: false })}`;
@@ -177,4 +175,4 @@ expertiseSelect.addEventListener("change", render);
 buildingSelect.addEventListener("change", render);
 render();
 
-//# debugId=944BFC6BA5BAAFDF64756E2164756E21
+//# debugId=360E975B4A4AE32464756E2164756E21

File diff ditekan karena terlalu besar
+ 0 - 0
www/roi.js.map


File diff ditekan karena terlalu besar
+ 202 - 101
www/roi_ai1.json


File diff ditekan karena terlalu besar
+ 202 - 101
www/roi_ci1.json


File diff ditekan karena terlalu besar
+ 202 - 101
www/roi_ic1.json


File diff ditekan karena terlalu besar
+ 202 - 101
www/roi_nc1.json


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini