market.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. from __future__ import annotations
  2. import collections
  3. import concurrent.futures
  4. import dataclasses
  5. import datetime
  6. import statistics
  7. import sys
  8. import typing
  9. import cache
  10. from config import config
  11. def main() -> None:
  12. raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
  13. if len(sys.argv) > 1:
  14. exchange_tickers = sys.argv[1:]
  15. for ticker in exchange_tickers:
  16. (price,) = (p for p in raw_prices if p['FullTicker'] == ticker)
  17. a = analyze_price_chart(ticker, (price['Bid'] + price['Ask']) / 2) # pyright: ignore[reportOperatorIssue]
  18. print(f'{ticker}: bids filled = {a.bids_filled:6.0f}, asks filled = {a.asks_filled:6.0f}, profit per interval = {a.profits:10.1f}')
  19. return
  20. check_cxos()
  21. markets: dict[str, list[Market]] = collections.defaultdict(list)
  22. with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
  23. futures: list[concurrent.futures.Future[Market | None]] = []
  24. for price in raw_prices:
  25. futures.append(executor.submit(analyze_raw_price, price))
  26. for future in futures:
  27. if (market := future.result()) is not None:
  28. markets[market.exchange_code].append(market)
  29. executor.shutdown()
  30. print(' mat bid ask spread bids filled asks filled profit p75 fill time')
  31. for commodities in markets.values():
  32. commodities.sort(reverse=True)
  33. for m in commodities:
  34. print(f'{m.ticker:>4}.{m.exchange_code} {m.bid:5} {m.ask:5} {m.spread*100: 5.0f}% {m.chart_analysis.bids_filled:12.0f} '
  35. f'{m.chart_analysis.asks_filled:12.0f} {m.chart_analysis.profits:10.0f} {format_td(m.chart_analysis.p75_fill_time)}')
  36. print()
  37. def check_cxos() -> None:
  38. warehouses: typing.Sequence[Warehouse] = cache.get('https://rest.fnar.net/sites/warehouses/' + config.username,
  39. headers={'Authorization': config.fio_rest_key})
  40. for warehouse in warehouses:
  41. storage: Storage = cache.get(f'https://rest.fnar.net/storage/{config.username}/{warehouse["StoreId"]}',
  42. headers={'Authorization': config.fio_rest_key})
  43. for item in storage['StorageItems']:
  44. threshold = config.market.mm_items.get(item['MaterialTicker'])
  45. if threshold is not None and item['MaterialAmount'] > threshold:
  46. print(f'{item["MaterialAmount"] - threshold} {item["MaterialTicker"]} at {warehouse["LocationNaturalId"]}')
  47. def analyze_raw_price(price: RawPrice) -> Market | None:
  48. if (traded := price['AverageTraded7D']) is None or traded < 100:
  49. return
  50. if price['Bid'] is None or price['Ask'] is None:
  51. return
  52. if (high := price['HighYesterday']) is None or (low := price['LowYesterday']) is None:
  53. return
  54. if (high - low) / high < 0.1:
  55. return
  56. spread = (price['Ask'] - price['Bid']) / price['Ask']
  57. if spread < 0.15:
  58. return
  59. chart_analysis = analyze_price_chart(price['FullTicker'], (price['Bid'] + price['Ask']) / 2)
  60. return Market(price['ExchangeCode'], price['MaterialTicker'], bid=price['Bid'], ask=price['Ask'],
  61. spread=spread, chart_analysis=chart_analysis)
  62. def analyze_price_chart(exchange_ticker: str, midpoint: float) -> ChartAnalysis:
  63. '''use price chart to estimate how long it takes to fill a bid and then an ask'''
  64. pcpoints: list[PriceChartPoint] = [p for p in cache.get('https://rest.fnar.net/exchange/cxpc/' + exchange_ticker)
  65. if p['Interval'] == 'MINUTE_FIVE']
  66. pcpoints.reverse()
  67. five_min = 5 * 60 * 1000
  68. cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=30)
  69. asks_filled: list[AskFilled] = []
  70. fill_time = []
  71. r = ChartAnalysis(bids_filled=0, asks_filled=0, profits=0, p75_fill_time=datetime.timedelta.max)
  72. for hist in pcpoints:
  73. if datetime.datetime.fromtimestamp(hist['DateEpochMs'] // 1000, datetime.UTC) < cutoff:
  74. continue
  75. time = hist['DateEpochMs'] // five_min
  76. bids = asks = 0
  77. if hist['Low'] > midpoint:
  78. asks = hist['Traded']
  79. r.asks_filled += asks
  80. elif hist['High'] < midpoint:
  81. bids = hist['Traded']
  82. r.bids_filled += bids
  83. elif hist['High'] == hist['Low']: # all trades right at midpoint
  84. assert hist['High'] == midpoint
  85. else:
  86. interval_bids = (hist['High'] * hist['Traded'] - hist['Volume']) / (hist['High'] - hist['Low'])
  87. interval_asks = hist['Traded'] - interval_bids
  88. r.bids_filled += interval_bids
  89. r.asks_filled += interval_asks
  90. bids = int(interval_bids)
  91. asks = int(interval_asks)
  92. if bids and asks:
  93. intra_interval_trades = min(bids, asks)
  94. r.profits += (hist['High'] - hist['Low']) * intra_interval_trades
  95. bids -= intra_interval_trades
  96. asks -= intra_interval_trades
  97. assert bids == 0 or asks == 0
  98. while bids > 0 and len(asks_filled) > 0:
  99. profit = (asks_filled[-1].price - hist['Low'])
  100. if asks_filled[-1].amount >= bids:
  101. r.profits += bids * profit
  102. asks_filled[-1].amount -= bids
  103. if asks_filled[-1].amount == 0:
  104. fill_time.append(asks_filled[-1].time - time)
  105. asks_filled.pop()
  106. bids = 0
  107. else:
  108. r.profits += asks_filled[-1].amount * profit
  109. bids -= asks_filled[-1].amount
  110. fill_time.append(asks_filled[-1].time - time)
  111. asks_filled.pop()
  112. if asks:
  113. asks_filled.append(AskFilled(price=hist['High'], amount=asks, time=time))
  114. if len(fill_time) > 0:
  115. r.p75_fill_time = statistics.quantiles(fill_time, n=4)[2] * datetime.timedelta(minutes=5)
  116. return r
  117. def format_td(td: datetime.timedelta) -> str:
  118. if td == datetime.timedelta.max:
  119. return '∞'
  120. days, seconds = divmod(td.total_seconds(), 24 * 60 * 60)
  121. hours = seconds / (60 * 60)
  122. return f'{int(days)}d {hours:4.1f}h'
  123. class ExchangeOrder(typing.TypedDict):
  124. MaterialTicker: str
  125. ExchangeCode: str
  126. OrderType: typing.Literal['SELLING'] | typing.Literal['BUYING']
  127. Status: typing.Literal['FILLED'] | typing.Literal['PARTIALLY_FILLED'] | typing.Literal['FILLED']
  128. Amount: int
  129. Limit: float
  130. class ExchangeSummary(typing.TypedDict):
  131. MaterialTicker: str
  132. ExchangeCode: str
  133. Bid: float | None
  134. Ask: float | None
  135. class Warehouse(typing.TypedDict):
  136. StoreId: str
  137. LocationNaturalId: str
  138. class Storage(typing.TypedDict):
  139. Name: str
  140. StorageItems: typing.Sequence[StorageItem]
  141. WeightLoad: float
  142. VolumeLoad: float
  143. Type: typing.Literal['STORE', 'WAREHOUSE_STORE', 'FTL_FUEL_STORE', 'STL_FUEL_STORE', 'SHIP_STORE']
  144. class StorageItem(typing.TypedDict):
  145. MaterialTicker: str | None # shipment blocks are None
  146. MaterialAmount: int
  147. Type: typing.Literal['INVENTORY', 'SHIPMENT']
  148. class RawPrice(typing.TypedDict):
  149. FullTicker: str
  150. MaterialTicker: str
  151. ExchangeCode: str
  152. Bid: float | None
  153. Ask: float | None
  154. HighYesterday: float | None
  155. LowYesterday: float | None
  156. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  157. class PriceChartPoint(typing.TypedDict):
  158. Interval: typing.Literal['MINUTE_FIVE'] | typing.Literal['MINUTE_FIFTEEN'] | typing.Literal['MINUTE_THIRTY'] | typing.Literal['HOUR_ONE'] | typing.Literal['HOUR_TWO'] | typing.Literal['HOUR_FOUR'] | typing.Literal['HOUR_SIX'] | typing.Literal['HOUR_TWELVE'] | typing.Literal['DAY_ONE'] | typing.Literal['DAY_THREE']
  159. DateEpochMs: int
  160. High: float
  161. Low: float
  162. Volume: float
  163. Traded: int
  164. @dataclasses.dataclass(eq=False, slots=True)
  165. class AskFilled:
  166. price: float
  167. amount: int
  168. time: int
  169. @dataclasses.dataclass(eq=False, slots=True)
  170. class ChartAnalysis:
  171. bids_filled: float
  172. asks_filled: float
  173. profits: float
  174. p75_fill_time: datetime.timedelta
  175. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  176. class Market:
  177. exchange_code: str
  178. ticker: str
  179. bid: float
  180. ask: float
  181. spread: float
  182. chart_analysis: ChartAnalysis
  183. def __lt__(self, o: Market) -> bool:
  184. return self.chart_analysis.profits < o.chart_analysis.profits
  185. if __name__ == '__main__':
  186. main()