mat.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import * as Plot from '@observablehq/plot';
  2. import * as d3 from 'd3';
  3. import {cachedFetchJSON} from './cache';
  4. const tickerSelect = document.querySelector('select#ticker') as HTMLSelectElement;
  5. const charts = document.querySelector('#charts')!;
  6. (async function () {
  7. const materials: Material[] = await fetch('https://rest.fnar.net/material/allmaterials').then((r) => r.json());
  8. const selected = document.location.hash.substring(1);
  9. for (const mat of materials.sort((a, b) => a.Ticker.localeCompare(b.Ticker))) {
  10. const option = document.createElement('option');
  11. option.value = mat.Ticker;
  12. option.textContent = `${mat.Ticker} ${mat.Name}`;
  13. if (mat.Ticker === selected)
  14. option.selected = true;
  15. tickerSelect.appendChild(option);
  16. }
  17. if (selected)
  18. render();
  19. })();
  20. tickerSelect.addEventListener('change', async () => {
  21. await render();
  22. document.location.hash = tickerSelect.value;
  23. });
  24. async function render() {
  25. charts.innerHTML = '';
  26. const ticker = tickerSelect.value;
  27. const cxpc = await Promise.all([
  28. getCXPC(ticker, 'NC1'), getCXPC(ticker, 'CI1'),
  29. getCXPC(ticker, 'IC1'), getCXPC(ticker, 'AI1'),
  30. ]);
  31. let minDate = null, maxDate = null;
  32. let maxPrice = 0, maxTraded = 0;
  33. for (const cxPrices of cxpc)
  34. for (const p of cxPrices) {
  35. if (minDate === null || p.DateEpochMs < minDate) minDate = p.DateEpochMs;
  36. if (maxDate === null || p.DateEpochMs > maxDate) maxDate = p.DateEpochMs;
  37. if (p.High > maxPrice) maxPrice = p.High;
  38. if (p.Traded > maxTraded) maxTraded = p.Traded;
  39. }
  40. if (minDate === null || maxDate === null)
  41. throw new Error('no data');
  42. const dateRange: [Date, Date] = [new Date(minDate), new Date(maxDate)];
  43. const cxpcRange = {dateRange, maxPrice, maxTraded};
  44. charts.append(...await Promise.all([
  45. renderExchange('NC1', ticker, cxpcRange, cxpc[0]), renderExchange('CI1', ticker, cxpcRange, cxpc[1]),
  46. renderExchange('IC1', ticker, cxpcRange, cxpc[2]), renderExchange('AI1', ticker, cxpcRange, cxpc[3]),
  47. ]));
  48. }
  49. async function getCXPC(ticker: string, cx: string): Promise<PriceChartPoint[]> {
  50. const cxpc: PriceChartPoint[] = await cachedFetchJSON(`https://rest.fnar.net/exchange/cxpc/${ticker}.${cx}`);
  51. const threshold = Date.now() - 100 * 24 * 60 * 60 * 1000; // work around FIO bug that shows old data
  52. return cxpc.filter((p) => p.Interval === 'HOUR_TWELVE' && p.DateEpochMs > threshold);
  53. }
  54. async function renderExchange(cx: string, ticker: string, cxpcRange: CXPCRange, cxpc: PriceChartPoint[]): Promise<HTMLElement> {
  55. const div = document.createElement('div');
  56. div.append(renderPriceChart(cx, cxpcRange, cxpc));
  57. div.append(renderOrderBook(await cachedFetchJSON(`https://rest.fnar.net/exchange/${ticker}.${cx}`)));
  58. return div;
  59. }
  60. function renderPriceChart(cx: string, {dateRange, maxPrice, maxTraded}: CXPCRange, cxpc: PriceChartPoint[]):
  61. SVGSVGElement | HTMLElement {
  62. const chartsWidth = charts.getBoundingClientRect().width;
  63. const numFormat = Plot.formatNumber();
  64. return Plot.plot({
  65. grid: true,
  66. width: chartsWidth < 600 ? chartsWidth : chartsWidth / 2 - 10,
  67. height: 400,
  68. marginLeft: 26 + 7 * Math.log10(maxPrice),
  69. marginRight: 26 + 7 * Math.log10(maxTraded),
  70. x: {domain: dateRange},
  71. y: {axis: 'left', label: cx, domain: [0, maxPrice * 1.1]},
  72. marks: [
  73. Plot.axisY(d3.ticks(0, maxTraded * 1.5, 10), {
  74. label: 'traded',
  75. anchor: 'right',
  76. y: (d) => (d / maxTraded) * maxPrice / 3,
  77. }),
  78. Plot.rectY(cxpc, {
  79. x: (p) => new Date(p.DateEpochMs),
  80. y: (p) => (p.Traded / maxTraded) * maxPrice / 3, // scale traded to price
  81. interval: 'day',
  82. fill: '#272',
  83. fillOpacity: 0.2,
  84. }),
  85. Plot.ruleX(cxpc, {
  86. x: (p) => new Date(p.DateEpochMs),
  87. y1: 'Low',
  88. y2: 'High',
  89. strokeWidth: 5,
  90. stroke: '#42a',
  91. }),
  92. Plot.dot(cxpc, {
  93. x: (p) => new Date(p.DateEpochMs),
  94. y: (p) => p.Volume / p.Traded,
  95. fill: '#a37',
  96. r: 2,
  97. }),
  98. Plot.crosshairX(cxpc, {
  99. x: (p) => new Date(p.DateEpochMs),
  100. y: (p) => p.Volume / p.Traded,
  101. textStroke: '#111',
  102. }),
  103. Plot.text(cxpc, Plot.pointerX({
  104. px: (p) => new Date(p.DateEpochMs),
  105. py: (p) => p.Volume / p.Traded,
  106. dy: -20,
  107. frameAnchor: "top-right",
  108. fontVariant: "tabular-nums",
  109. text: (p) => `${Plot.formatIsoDate(new Date(p.DateEpochMs))}\n` +
  110. `avg: $${numFormat(p.Volume / p.Traded)}\n` +
  111. `high: ${numFormat(p.High)}\n` +
  112. `low: ${numFormat(p.Low)}\n` +
  113. `traded: ${numFormat(p.Traded)}`
  114. })),
  115. ]
  116. });
  117. }
  118. function renderOrderBook(book: OrderBook): HTMLElement {
  119. const table = document.createElement('table');
  120. book.SellingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
  121. for (const order of book.SellingOrders) {
  122. if (book.MMSell !== null && order.ItemCost >= book.MMSell && order.ItemCount !== null) continue;
  123. const row = renderOrder(order);
  124. row.classList.add('sell');
  125. table.appendChild(row);
  126. }
  127. book.BuyingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
  128. for (const order of book.BuyingOrders) {
  129. if (book.MMBuy !== null && order.ItemCost <= book.MMBuy && order.ItemCount !== null) continue;
  130. const row = renderOrder(order);
  131. row.classList.add('buy');
  132. table.appendChild(row);
  133. }
  134. const div = document.createElement('div');
  135. div.classList.add('cxob');
  136. div.appendChild(table);
  137. // center the first bid
  138. const firstBuyRow: HTMLTableRowElement | null = table.querySelector('tr.buy');
  139. if (firstBuyRow !== null) {
  140. const centerFirstBuyOrder = () => {
  141. if (div.isConnected)
  142. div.scrollTop = Math.max(0, firstBuyRow.offsetTop - div.clientHeight / 2 + 10);
  143. else
  144. requestAnimationFrame(centerFirstBuyOrder);
  145. };
  146. requestAnimationFrame(centerFirstBuyOrder);
  147. }
  148. return div;
  149. }
  150. function renderOrder(order: Order): HTMLTableRowElement {
  151. const row = document.createElement('tr');
  152. row.insertCell().textContent = order.CompanyName;
  153. row.insertCell().textContent = order.CompanyCode;
  154. row.insertCell().textContent = order.ItemCount === null ? '∞' : order.ItemCount.toString();
  155. row.insertCell().textContent = `${formatPrice(order.ItemCost)}`;
  156. return row;
  157. }
  158. const formatPrice = new Intl.NumberFormat(undefined, {minimumSignificantDigits: 3, maximumSignificantDigits: 3}).format;
  159. interface Material {
  160. Ticker: string;
  161. Name: string;
  162. }
  163. interface PriceChartPoint {
  164. Interval: 'MINUTE_FIVE' | 'MINUTE_FIFTEEN' | 'MINUTE_THIRTY' | 'HOUR_ONE' | 'HOUR_TWO' | 'HOUR_FOUR' | 'HOUR_SIX' | 'HOUR_TWELVE' | 'DAY_ONE' | 'DAY_THREE';
  165. DateEpochMs: number;
  166. High: number;
  167. Low: number;
  168. Volume: number;
  169. Traded: number;
  170. }
  171. interface CXPCRange {
  172. dateRange: [Date, Date];
  173. maxPrice: number;
  174. maxTraded: number;
  175. }
  176. interface OrderBook {
  177. BuyingOrders: Order[];
  178. SellingOrders: Order[];
  179. MMBuy: number | null;
  180. MMSell: number | null;
  181. }
  182. interface Order {
  183. CompanyName: string;
  184. CompanyCode: string;
  185. ItemCount: number | null;
  186. ItemCost: number;
  187. }