Bladeren bron

roi: show calculation

raylu 1 week geleden
bovenliggende
commit
6c870b194b
3 gewijzigde bestanden met toevoegingen van 59 en 13 verwijderingen
  1. 21 3
      roi.py
  2. 36 9
      ts/roi.ts
  3. 2 1
      www/style.css

+ 21 - 3
roi.py

@@ -41,19 +41,25 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 		prices: typing.Mapping[str, Price]) -> Profit | None:
 	if len(recipe['Outputs']) == 0:
 		return
+
+	outputs: list[MatPrice] = []
+	revenue = 0
 	output_prices: dict[str, PriceNonNull] = {}
 	for output in recipe['Outputs']:
 		price = prices[output['Ticker']]
 		if price.vwap_7d is None or price.average_traded_7d is None:
 			return # skip recipes with thinly traded outputs
 		output_prices[output['Ticker']] = typing.cast(PriceNonNull, price)
+		outputs.append(MatPrice(output['Ticker'], output['Amount'], price.vwap_7d))
+		revenue += price.vwap_7d * output['Amount']
 
+	input_costs: list[MatPrice] = []
 	cost = 0
 	for input in recipe['Inputs']:
 		if (input_cost := prices[input['Ticker']].vwap_7d) is None:
 			return # skip recipes with thinly traded inputs
+		input_costs.append(MatPrice(input['Ticker'], input['Amount'], input_cost))
 		cost += input_cost * input['Amount']
-	revenue = sum(output_prices[output['Ticker']].vwap_7d * output['Amount'] for output in recipe['Outputs'])
 	profit_per_run = revenue - cost
 
 	building = buildings[recipe['BuildingTicker']]
@@ -76,12 +82,15 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 		sum(materials[output['Ticker']]['Weight'] * output['Amount'] for output in recipe['Outputs']),
 		sum(materials[output['Ticker']]['Volume'] * output['Amount'] for output in recipe['Outputs']),
 	) * runs_per_day / area
-	return Profit([output['Ticker'] for output in recipe['Outputs']], recipe['RecipeName'],
+	return Profit(outputs, recipe['RecipeName'],
 			expertise=building['Expertise'],
 			profit_per_day=(profit_per_run * runs_per_day - worker_consumable_daily_cost),
 			area=area,
 			capex=capex,
 			cost_per_day=cost_per_day,
+			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=output_prices[lowest_liquidity['Ticker']].average_traded_7d)
@@ -158,13 +167,16 @@ class PriceNonNull:
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class Profit:
-	output: typing.Collection[str]
+	outputs: typing.Collection[MatPrice]
 	recipe: str
 	expertise: str
 	profit_per_day: float
 	area: float
 	capex: float
 	cost_per_day: float
+	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
@@ -176,5 +188,11 @@ class Profit:
 			other_break_even = 10000 - other.profit_per_day
 		return break_even < other_break_even
 
+@dataclasses.dataclass(eq=False, frozen=True, slots=True)
+class MatPrice:
+	ticker: str
+	amount: int
+	vwap_7d: float
+
 if __name__ == '__main__':
 	main()

+ 36 - 9
ts/roi.ts

@@ -28,10 +28,11 @@ for (const key of Object.keys(expertise)) {
 	expertiseSelect.appendChild(option);
 }
 
+const formatDecimal = new Intl.NumberFormat(undefined,
+		{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
+const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
+
 async function render() {
-	const formatDecimal = new Intl.NumberFormat(undefined,
-			{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
-	const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
 	const tbody = document.querySelector('tbody')!;
 	tbody.innerHTML = '';
 
@@ -43,13 +44,13 @@ async function render() {
 		if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value)
 			continue;
 		const tr = document.createElement('tr');
-		const profit_per_area = p.profit_per_day / p.area;
-		const break_even = p.profit_per_day > 0 ? p.capex / p.profit_per_day : Infinity;
+		const profitPerArea = p.profit_per_day / p.area;
+		const breakEven = p.profit_per_day > 0 ? p.capex / p.profit_per_day : Infinity;
 		tr.innerHTML = `
-			<td>${p.output.join(', ')}</td>
+			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
-			<td style="color: ${color(profit_per_area, 0, 250)}">${formatDecimal(profit_per_area)}</td>
-			<td><span style="color: ${color(break_even, 30, 3)}">${formatDecimal(break_even)}</span>d</td>
+			<td style="color: ${color(profitPerArea, 0, 250)}">${formatDecimal(profitPerArea)}</td>
+			<td><span style="color: ${color(breakEven, 30, 3)}">${formatDecimal(breakEven)}</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.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
@@ -58,8 +59,20 @@ async function render() {
 				<span style="color: ${color(volumeRatio, 0.05, 0.002)}">${formatWhole(p.average_traded_7d)}</span>
 			</td>
 		`;
+
 		const output = tr.querySelector('td')!;
 		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` +
+			`${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(profitPerArea)}`;
+
 		tbody.appendChild(tr);
 	}
 	document.getElementById('last-updated')!.textContent =
@@ -72,20 +85,34 @@ 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');
+}
+
 setupPopover();
 lowVolume.addEventListener('change', render);
 expertiseSelect.addEventListener('change', render);
 render();
 
 interface Profit {
-	output: string[]
+	outputs: MatPrice[]
 	recipe: string
 	expertise: keyof typeof expertise
 	profit_per_day: number
 	area: number
 	capex: number
 	cost_per_day: number
+	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
 }
+
+interface MatPrice {
+	ticker: string
+	amount: number
+	vwap_7d: number
+}

+ 2 - 1
www/style.css

@@ -154,8 +154,9 @@ main.buy {
 }
 
 [popover="hint"] {
+	white-space: pre-wrap;
 	inset: unset;
-	max-width: 320px;
+	max-width: 400px;
 	padding: 0.5em 0.7em;
 	color: inherit;
 	background-color: #222;