3 Commits ac205e481f ... a92c6f321d

Author SHA1 Message Date
  raylu a92c6f321d mat: show recipes 3 days ago
  raylu 726c1a292e mat: cxob 3 days ago
  raylu 978578e1e4 shipbuilding 4 days ago
3 changed files with 218 additions and 8 deletions
  1. 82 0
      shipbuilding.py
  2. 107 8
      ts/mat.ts
  3. 29 0
      www/style.css

+ 82 - 0
shipbuilding.py

@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import collections
+import typing
+
+import cache
+import roi
+
+def main() -> None:
+	blueprint = {
+		'FFC': 1,
+		'FSE': 1,
+		'LFE': 2,
+		'MFE': 2,
+		'QCR': 1,
+		'SFE': 1,
+		'LCB': 1,
+		'MFL': 1,
+		'MSL': 1,
+		'LHP': 94,
+		'SSC': 128,
+		'BR1': 1,
+		'CQM': 1,
+	}
+	prices: typing.Mapping[str, RawPrice] = {p['MaterialTicker']: p
+			for p in cache.get('https://refined-prun.github.io/refined-prices/all.json') if p['ExchangeCode'] == 'IC1'}
+	recipes = recipe_for_mats()
+
+	buildings: dict[str, set[str]] = collections.defaultdict(set)
+	cost = 0.0
+	for mat, amount in blueprint.items():
+		cost += analyze_mat(0, mat, amount, buildings, prices, recipes)
+		print()
+	print(f'total cost: {cost:,}')
+	for building, mats in buildings.items():
+		print(f'{building:3}: {", ".join(mats)}')
+
+def recipe_for_mats() -> dict[str, roi.Recipe]:
+	all_recipes: list[roi.Recipe] = cache.get('https://api.prunplanner.org/data/recipes/')
+	mat_recipes = collections.defaultdict(list) # all ways to make a mat
+	for recipe in all_recipes:
+		for output in recipe['outputs']:
+			mat_recipes[output['material_ticker']].append(recipe)
+
+	mat_recipe: dict[str, roi.Recipe] = {} # mats for which there's only one recipe to make
+	for mat, recipes in mat_recipes.items():
+		if len(recipes) == 1:
+			mat_recipe[mat] = recipes[0]
+	return mat_recipe
+
+def analyze_mat(level: int, mat: str, amount: float, buildings: dict[str, set[str]],
+		prices: typing.Mapping[str, RawPrice], recipes: dict[str, roi.Recipe]) -> float:
+	price = prices[mat]
+	traded = price['AverageTraded30D'] or 0
+	if (price['Supply'] > amount * 10 and traded > 0) or (price['Supply'] > amount * 4 and amount < traded):
+		print('\t' * level + f'{amount:g}×{mat} buy: {price["Ask"]:9,}, daily traded {traded:5.1f}, supply {price["Supply"]:4}')
+		assert price['Ask'] is not None
+		return price['Ask'] * amount
+	else:
+		if (recipe := recipes.get(mat)) is None:
+			print('\t' * level + f'{amount:g}×{mat} make (unknown recipe)')
+			return 0
+		else:
+			building = recipe['building_ticker']
+			print('\t' * level + f'{amount:g}×{mat} make ({building})')
+			buildings[building].add(mat)
+			total_cost = 0.0
+			for input_mat in recipe['inputs']:
+				input_amount = input_mat['material_amount'] * amount / recipe['outputs'][0]['material_amount']
+				total_cost += analyze_mat(level + 1, input_mat['material_ticker'], input_amount, buildings, prices, recipes)
+			print('\t' * level + f'\tcost: {total_cost:9,.2f}')
+		return total_cost
+
+class RawPrice(typing.TypedDict):
+	MaterialTicker: str
+	ExchangeCode: str
+	Ask: float | None
+	AverageTraded30D: float | None # averaged daily traded volume over last 30 days
+	Supply: int
+
+if __name__ == '__main__':
+	main()

+ 107 - 8
ts/mat.ts

@@ -27,9 +27,10 @@ tickerSelect.addEventListener('change', async () => {
 	
 	
 async function render() {
 async function render() {
 	charts.innerHTML = '';
 	charts.innerHTML = '';
+	const ticker = tickerSelect.value;
 	const cxpc = await Promise.all([
 	const cxpc = await Promise.all([
-		getCXPC(tickerSelect.value, 'NC1'), getCXPC(tickerSelect.value, 'CI1'),
-		getCXPC(tickerSelect.value, 'IC1'), getCXPC(tickerSelect.value, 'AI1'),
+		getCXPC(ticker, 'NC1'), getCXPC(ticker, 'CI1'),
+		getCXPC(ticker, 'IC1'), getCXPC(ticker, 'AI1'),
 	]);
 	]);
 
 
 	let minDate = null, maxDate = null;
 	let minDate = null, maxDate = null;
@@ -43,12 +44,14 @@ async function render() {
 		}
 		}
 	if (minDate === null || maxDate === null)
 	if (minDate === null || maxDate === null)
 		throw new Error('no data');
 		throw new Error('no data');
-
 	const dateRange: [Date, Date] = [new Date(minDate), new Date(maxDate)];
 	const dateRange: [Date, Date] = [new Date(minDate), new Date(maxDate)];
-	charts.append(
-		renderPriceChart('NC1', dateRange, maxPrice, maxTraded, cxpc[0]), renderPriceChart('CI1', dateRange, maxPrice, maxTraded, cxpc[1]),
-		renderPriceChart('IC1', dateRange, maxPrice, maxTraded, cxpc[2]), renderPriceChart('AI1', dateRange, maxPrice, maxTraded, cxpc[3]),
-	);
+	const cxpcRange = {dateRange, maxPrice, maxTraded};
+
+	charts.append(...await Promise.all([
+		renderExchange('NC1', ticker, cxpcRange, cxpc[0]), renderExchange('CI1', ticker, cxpcRange, cxpc[1]),
+		renderExchange('IC1', ticker, cxpcRange, cxpc[2]), renderExchange('AI1', ticker, cxpcRange, cxpc[3]),
+		renderRecipes(ticker),
+	]));
 }
 }
 
 
 async function getCXPC(ticker: string, cx: string): Promise<PriceChartPoint[]> {
 async function getCXPC(ticker: string, cx: string): Promise<PriceChartPoint[]> {
@@ -57,7 +60,14 @@ async function getCXPC(ticker: string, cx: string): Promise<PriceChartPoint[]> {
 	return cxpc.filter((p) => p.Interval === 'HOUR_TWELVE' && p.DateEpochMs > threshold);
 	return cxpc.filter((p) => p.Interval === 'HOUR_TWELVE' && p.DateEpochMs > threshold);
 }
 }
 
 
-function renderPriceChart(cx: string, dateRange: [Date, Date], maxPrice: number, maxTraded: number, cxpc: PriceChartPoint[]):
+async function renderExchange(cx: string, ticker: string, cxpcRange: CXPCRange, cxpc: PriceChartPoint[]): Promise<HTMLElement> {
+	const div = document.createElement('div');
+	div.append(renderPriceChart(cx, cxpcRange, cxpc));
+	div.append(renderOrderBook(await cachedFetchJSON(`https://rest.fnar.net/exchange/${ticker}.${cx}`)));
+	return div;
+}
+
+function renderPriceChart(cx: string, {dateRange, maxPrice, maxTraded}: CXPCRange, cxpc: PriceChartPoint[]):
 		SVGSVGElement | HTMLElement {
 		SVGSVGElement | HTMLElement {
 	const chartsWidth = charts.getBoundingClientRect().width;
 	const chartsWidth = charts.getBoundingClientRect().width;
 	const numFormat = Plot.formatNumber();
 	const numFormat = Plot.formatNumber();
@@ -116,6 +126,64 @@ function renderPriceChart(cx: string, dateRange: [Date, Date], maxPrice: number,
 	});
 	});
 }
 }
 
 
+function renderOrderBook(book: OrderBook): HTMLElement {
+	const table = document.createElement('table');
+	book.SellingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
+	for (const order of book.SellingOrders) {
+		if (book.MMSell !== null && order.ItemCost >= book.MMSell && order.ItemCount !== null) continue;
+		const row = renderOrder(order);
+		row.classList.add('sell');
+		table.appendChild(row);
+	}
+	book.BuyingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
+	for (const order of book.BuyingOrders) {
+		if (book.MMBuy !== null && order.ItemCost <= book.MMBuy && order.ItemCount !== null) continue;
+		const row = renderOrder(order);
+		row.classList.add('buy');
+		table.appendChild(row);
+	}
+
+	const div = document.createElement('div');
+	div.classList.add('cxob');
+	div.appendChild(table);
+	// center the first bid
+	const firstBuyRow: HTMLTableRowElement | null = table.querySelector('tr.buy');
+	if (firstBuyRow !== null) {
+		const centerFirstBuyOrder = () => {
+			if (div.isConnected)
+				div.scrollTop = Math.max(0, firstBuyRow.offsetTop - div.clientHeight / 2 + 10);
+			else
+				requestAnimationFrame(centerFirstBuyOrder);
+		};
+		requestAnimationFrame(centerFirstBuyOrder);
+	}
+	return div;
+}
+
+function renderOrder(order: Order): HTMLTableRowElement {
+	const row = document.createElement('tr');
+	row.insertCell().textContent = order.CompanyName;
+	row.insertCell().textContent = order.CompanyCode;
+	row.insertCell().textContent = order.ItemCount === null ? '∞' : order.ItemCount.toString();
+	row.insertCell().textContent = `${formatPrice(order.ItemCost)}`;
+	return row;
+}
+
+const formatPrice = new Intl.NumberFormat(undefined, {minimumSignificantDigits: 3, maximumSignificantDigits: 3}).format;
+
+async function renderRecipes(ticker: string): Promise<HTMLElement> {
+	const recipes: Recipe[] = await cachedFetchJSON(`https://rest.fnar.net/recipes/${ticker}`);
+
+	const section = document.createElement('section');
+	for (const recipe of recipes)
+		section.innerHTML += `<div class="recipe">
+			${recipe.Inputs.map((i) => `${i.Amount}×${i.CommodityTicker}`).join(' ')}
+			<span class="building">${recipe.BuildingTicker}</span>
+			${recipe.Outputs.map((o) => `${o.Amount}×${o.CommodityTicker}`).join(' ')}
+		</div>`;
+	return section;
+}
+
 interface Material {
 interface Material {
 	Ticker: string;
 	Ticker: string;
 	Name: string;
 	Name: string;
@@ -129,3 +197,34 @@ interface PriceChartPoint {
 	Volume: number;
 	Volume: number;
 	Traded: number;
 	Traded: number;
 }
 }
+
+interface CXPCRange {
+	dateRange: [Date, Date];
+	maxPrice: number;
+	maxTraded: number;
+}
+
+interface OrderBook {
+	BuyingOrders: Order[];
+	SellingOrders: Order[];
+	MMBuy: number | null;
+	MMSell: number | null;
+}
+
+interface Order {
+	CompanyName: string;
+	CompanyCode: string;
+	ItemCount: number | null;
+	ItemCost: number;
+}
+
+interface Recipe {
+	BuildingTicker: string;
+	Inputs: RecipeMat[];
+	Outputs: RecipeMat[];
+}
+
+interface RecipeMat {
+	CommodityTicker: string;
+	Amount: number;
+}

+ 29 - 0
www/style.css

@@ -108,15 +108,44 @@ main.buy {
 main.mat {
 main.mat {
 	width: 90%;
 	width: 90%;
 	min-width: 500px;
 	min-width: 500px;
+
 	#charts {
 	#charts {
 		display: flex;
 		display: flex;
 		flex-wrap: wrap;
 		flex-wrap: wrap;
 		justify-content: space-between;
 		justify-content: space-between;
 		row-gap: 2em;
 		row-gap: 2em;
 		margin-top: 2em;
 		margin-top: 2em;
+
 		svg {
 		svg {
 			display: inline-block;
 			display: inline-block;
 		}
 		}
+
+		div.cxob {
+			max-height: 10em;
+			overflow-y: scroll;
+			font-size: 12px;
+
+			tr.sell {
+				color: #f80;
+			}
+			tr.buy {
+				color: #0aa;
+			}
+		}
+
+		div.recipe {
+			margin-bottom: 1em;
+
+			span.building {
+				display: inline-flex;
+				height: 50px;
+				width: 50px;
+				align-items: center;
+				justify-content: center;
+				background-color: #222;
+				border: 1px solid #555;
+			}
+		}
 	}
 	}
 }
 }