2 Комити 18e6372721 ... 6c870b194b

Аутор SHA1 Порука Датум
  raylu 6c870b194b roi: show calculation пре 1 недеља
  raylu 20fb4c0e3a roi: handle multi-output recipes пре 1 недеља
3 измењених фајлова са 82 додато и 24 уклоњено
  1. 44 14
      roi.py
  2. 36 9
      ts/roi.ts
  3. 2 1
      www/style.css

+ 44 - 14
roi.py

@@ -39,45 +39,61 @@ def main() -> None:
 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],
 		prices: typing.Mapping[str, Price]) -> Profit | None:
-	try:
-		(output,) = recipe['Outputs']
-	except ValueError: # skip recipes that don't have exactly 1 output
-		return
-	output_price = prices[output['Ticker']]
-	if output_price.vwap_7d is None or output_price.average_traded_7d is None: # skip recipes with thinly traded output
+	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 = output_price.vwap_7d * output['Amount']
+	profit_per_run = revenue - cost
+
 	building = buildings[recipe['BuildingTicker']]
 	area = building['AreaCost'] + 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)
-	profit_per_run = revenue - cost
 	runs_per_day = 24 * 60 * 60 * 1000 / recipe['TimeMs']
 	if 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
-	output_per_day = output['Amount'] * runs_per_day
+
+	lowest_liquidity = min(recipe['Outputs'],
+			key=lambda output: output['Amount'] / output_prices[output['Ticker']].average_traded_7d)
+	output_per_day = lowest_liquidity['Amount'] * runs_per_day
+
 	logistics_per_area = max(
 		sum(materials[input['Ticker']]['Weight'] * input['Amount'] for input in recipe['Inputs']),
 		sum(materials[input['Ticker']]['Volume'] * input['Amount'] for input in recipe['Inputs']),
-		materials[output['Ticker']]['Weight'] * output['Amount'],
-		materials[output['Ticker']]['Volume'] * output['Amount'],
+		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'], 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_price.average_traded_7d)
+			average_traded_7d=output_prices[lowest_liquidity['Ticker']].average_traded_7d)
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
 	return sum(bc['Amount'] * prices[bc['CommodityTicker']].vwap_7d for bc in building['BuildingCosts']) # pyright: ignore[reportOperatorIssue]
@@ -144,15 +160,23 @@ class Price:
 	average_traded_7d: float | None
 	vwap_30d: float | None
 
+@dataclasses.dataclass(eq=False, frozen=True, slots=True)
+class PriceNonNull:
+	vwap_7d: float
+	average_traded_7d: float
+
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class Profit:
-	output: 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
@@ -164,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}</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;