roi.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import {setupPopover} from './popover';
  2. const roiCache: Record<string, Promise<{lastModified: Date, profits: Profit[]}>> = {};
  3. async function getROI(cx: string) {
  4. const response = await fetch(`/roi_${cx.toLowerCase()}.json`);
  5. const lastModified = new Date(response.headers.get('last-modified')!);
  6. const profits = await response.json();
  7. return {lastModified, profits};
  8. }
  9. const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
  10. const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
  11. {
  12. const savedCx = localStorage.getItem('cx');
  13. if (savedCx !== null)
  14. cxSelect.value = savedCx;
  15. }
  16. const expertise = {
  17. AGRICULTURE: 'agri',
  18. CHEMISTRY: 'chem',
  19. CONSTRUCTION: 'const',
  20. ELECTRONICS: 'elec',
  21. FOOD_INDUSTRIES: 'food ind',
  22. FUEL_REFINING: 'fuel',
  23. MANUFACTURING: 'mfg',
  24. METALLURGY: 'metal',
  25. RESOURCE_EXTRACTION: 'res ext',
  26. } as const;
  27. const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement;
  28. for (const key of Object.keys(expertise)) {
  29. const option = document.createElement('option');
  30. option.value = key;
  31. option.textContent = key.replace('_', ' ').toLowerCase();
  32. expertiseSelect.appendChild(option);
  33. }
  34. const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
  35. const formatDecimal = new Intl.NumberFormat(undefined,
  36. {maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
  37. const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
  38. async function render() {
  39. const tbody = document.querySelector('tbody')!;
  40. tbody.innerHTML = '';
  41. const cx = cxSelect.value;
  42. if (!roiCache[cx])
  43. roiCache[cx] = getROI(cx);
  44. const {lastModified, profits} = await roiCache[cx];
  45. const buildingTickers = new Set(profits.map(p => p.building));
  46. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  47. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  48. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  49. let selectedBuilding = buildingSelect.value;
  50. let buildingFound = false;
  51. buildingSelect.innerHTML = '<option value="">(all)</option>';
  52. for (const building of buildings)
  53. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  54. const option = document.createElement('option');
  55. option.value = building.ticker;
  56. option.textContent = building.ticker;
  57. if (building.ticker === selectedBuilding) {
  58. buildingFound = true;
  59. option.selected = true;
  60. }
  61. buildingSelect.appendChild(option);
  62. }
  63. if (!buildingFound)
  64. selectedBuilding = '';
  65. for (const p of profits) {
  66. const volumeRatio = p.output_per_day / p.average_traded_7d;
  67. if (!lowVolume.checked && volumeRatio > 0.05)
  68. continue;
  69. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value)
  70. continue;
  71. if (selectedBuilding !== '' && p.building !== selectedBuilding)
  72. continue;
  73. const tr = document.createElement('tr');
  74. const profitPerArea = p.profit_per_day / p.area;
  75. const breakEven = p.profit_per_day > 0 ? p.capex / p.profit_per_day : Infinity;
  76. tr.innerHTML = `
  77. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  78. <td>${expertise[p.expertise]}</td>
  79. <td style="color: ${color(profitPerArea, 0, 300)}">${formatDecimal(profitPerArea)}</td>
  80. <td><span style="color: ${color(breakEven, 30, 3)}">${formatDecimal(breakEven)}</span>d</td>
  81. <td style="color: ${color(p.capex, 300_000, 40_000)}">${formatWhole(p.capex)}</td>
  82. <td style="color: ${color(p.cost_per_day, 40_000, 1_000)}">${formatWhole(p.cost_per_day)}</td>
  83. <td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
  84. <td>
  85. ${formatDecimal(p.output_per_day)}<br>
  86. <span style="color: ${color(volumeRatio, 0.05, 0.002)}">${formatWhole(p.average_traded_7d)}</span>
  87. </td>
  88. `;
  89. const output = tr.querySelector('td')!;
  90. output.dataset.tooltip = p.recipe;
  91. const profitCell = tr.querySelectorAll('td')[2];
  92. const revenue = p.outputs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
  93. const inputCost = p.input_costs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
  94. profitCell.dataset.tooltip = formatMatPrices(p.outputs) + '\n\n' +
  95. formatMatPrices(p.input_costs) + '\n' +
  96. 'worker consumables: ' + formatWhole(p.worker_consumable_cost_per_day) + '\n\n' +
  97. `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` +
  98. `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
  99. `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(profitPerArea)}`;
  100. tbody.appendChild(tr);
  101. }
  102. document.getElementById('last-updated')!.textContent =
  103. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  104. localStorage.setItem('cx', cx);
  105. }
  106. function color(n: number, low: number, high: number): string {
  107. // scale n from low..high to 0..1 clamped
  108. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  109. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  110. }
  111. function formatMatPrices(matPrices: MatPrice[]): string {
  112. return matPrices.map(({ticker, amount, vwap_7d}) =>
  113. `${ticker}: ${amount} × ${formatDecimal(vwap_7d)} = ${formatWhole(amount * vwap_7d)}`).join('\n');
  114. }
  115. setupPopover();
  116. lowVolume.addEventListener('change', render);
  117. cxSelect.addEventListener('change', render);
  118. expertiseSelect.addEventListener('change', render);
  119. buildingSelect.addEventListener('change', render);
  120. render();
  121. interface Profit {
  122. outputs: MatPrice[]
  123. recipe: string
  124. expertise: keyof typeof expertise
  125. building: string
  126. profit_per_day: number
  127. area: number
  128. capex: number
  129. cost_per_day: number
  130. input_costs: MatPrice[]
  131. worker_consumable_cost_per_day: number
  132. runs_per_day: number
  133. logistics_per_area: number
  134. output_per_day: number
  135. average_traded_7d: number
  136. }
  137. interface MatPrice {
  138. ticker: string
  139. amount: number
  140. vwap_7d: number
  141. }