market.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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. if item['MaterialTicker'] is None:
  45. continue
  46. threshold = config.market.mm_items.get(item['MaterialTicker'])
  47. if threshold is not None and item['MaterialAmount'] > threshold:
  48. print(f'{item["MaterialAmount"] - threshold} {item["MaterialTicker"]} at {warehouse["LocationNaturalId"]}')
  49. def analyze_raw_price(price: RawPrice) -> Market | None:
  50. if (traded := price['AverageTraded7D']) is None or traded < 100:
  51. return
  52. if price['Bid'] is None or price['Ask'] is None:
  53. return
  54. if (high := price['HighYesterday']) is None or (low := price['LowYesterday']) is None:
  55. return
  56. if (high - low) / high < 0.1:
  57. return
  58. spread = (price['Ask'] - price['Bid']) / price['Ask']
  59. if spread < 0.15:
  60. return
  61. chart_analysis = analyze_price_chart(price['FullTicker'], (price['Bid'] + price['Ask']) / 2)
  62. return Market(price['ExchangeCode'], price['MaterialTicker'], bid=price['Bid'], ask=price['Ask'],
  63. spread=spread, chart_analysis=chart_analysis)
  64. def analyze_price_chart(exchange_ticker: str, midpoint: float) -> ChartAnalysis:
  65. '''use price chart to estimate how long it takes to fill a bid and then an ask'''
  66. pcpoints: list[PriceChartPoint] = [p for p in cache.get('https://rest.fnar.net/exchange/cxpc/' + exchange_ticker)
  67. if p['Interval'] == 'MINUTE_FIVE']
  68. pcpoints.reverse()
  69. five_min = 5 * 60 * 1000
  70. cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=30)
  71. asks_filled: list[AskFilled] = []
  72. fill_time = []
  73. r = ChartAnalysis(bids_filled=0, asks_filled=0, profits=0, p75_fill_time=datetime.timedelta.max)
  74. for hist in pcpoints:
  75. if datetime.datetime.fromtimestamp(hist['DateEpochMs'] // 1000, datetime.UTC) < cutoff:
  76. continue
  77. time = hist['DateEpochMs'] // five_min
  78. bids = asks = 0
  79. if hist['Low'] > midpoint:
  80. asks = hist['Traded']
  81. r.asks_filled += asks
  82. elif hist['High'] < midpoint:
  83. bids = hist['Traded']
  84. r.bids_filled += bids
  85. elif hist['High'] == hist['Low']: # all trades right at midpoint
  86. assert hist['High'] == midpoint
  87. else:
  88. interval_bids = (hist['High'] * hist['Traded'] - hist['Volume']) / (hist['High'] - hist['Low'])
  89. interval_asks = hist['Traded'] - interval_bids
  90. r.bids_filled += interval_bids
  91. r.asks_filled += interval_asks
  92. bids = int(interval_bids)
  93. asks = int(interval_asks)
  94. if bids and asks:
  95. intra_interval_trades = min(bids, asks)
  96. r.profits += (hist['High'] - hist['Low']) * intra_interval_trades
  97. bids -= intra_interval_trades
  98. asks -= intra_interval_trades
  99. assert bids == 0 or asks == 0
  100. while bids > 0 and len(asks_filled) > 0:
  101. profit = (asks_filled[-1].price - hist['Low'])
  102. if asks_filled[-1].amount >= bids:
  103. r.profits += bids * profit
  104. asks_filled[-1].amount -= bids
  105. if asks_filled[-1].amount == 0:
  106. fill_time.append(asks_filled[-1].time - time)
  107. asks_filled.pop()
  108. bids = 0
  109. else:
  110. r.profits += asks_filled[-1].amount * profit
  111. bids -= asks_filled[-1].amount
  112. fill_time.append(asks_filled[-1].time - time)
  113. asks_filled.pop()
  114. if asks:
  115. asks_filled.append(AskFilled(price=hist['High'], amount=asks, time=time))
  116. if len(fill_time) > 0:
  117. r.p75_fill_time = statistics.quantiles(fill_time, n=4)[2] * datetime.timedelta(minutes=5)
  118. return r
  119. def format_td(td: datetime.timedelta) -> str:
  120. if td == datetime.timedelta.max:
  121. return '∞'
  122. days, seconds = divmod(td.total_seconds(), 24 * 60 * 60)
  123. hours = seconds / (60 * 60)
  124. return f'{int(days)}d {hours:4.1f}h'
  125. class ExchangeOrder(typing.TypedDict):
  126. MaterialTicker: str
  127. ExchangeCode: str
  128. OrderType: typing.Literal['SELLING', 'BUYING']
  129. Status: typing.Literal['FILLED', 'PARTIALLY_FILLED']
  130. Amount: int
  131. Limit: float
  132. class ExchangeSummary(typing.TypedDict):
  133. MaterialTicker: str
  134. ExchangeCode: str
  135. Bid: float | None
  136. Ask: float | None
  137. class Warehouse(typing.TypedDict):
  138. StoreId: str
  139. LocationNaturalId: str
  140. class Storage(typing.TypedDict):
  141. Name: str
  142. StorageItems: typing.Sequence[StorageItem]
  143. WeightLoad: float
  144. VolumeLoad: float
  145. Type: typing.Literal['STORE', 'WAREHOUSE_STORE', 'FTL_FUEL_STORE', 'STL_FUEL_STORE', 'SHIP_STORE']
  146. class StorageItem(typing.TypedDict):
  147. MaterialTicker: str | None # shipment blocks are None
  148. MaterialAmount: int
  149. Type: typing.Literal['INVENTORY', 'SHIPMENT']
  150. class RawPrice(typing.TypedDict):
  151. FullTicker: str
  152. MaterialTicker: str
  153. ExchangeCode: str
  154. Bid: float | None
  155. Ask: float | None
  156. HighYesterday: float | None
  157. LowYesterday: float | None
  158. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  159. class PriceChartPoint(typing.TypedDict):
  160. Interval: typing.Literal['MINUTE_FIVE', 'MINUTE_FIFTEEN', 'MINUTE_THIRTY', 'HOUR_ONE', 'HOUR_TWO', 'HOUR_FOUR', 'HOUR_SIX', 'HOUR_TWELVE', 'DAY_ONE', 'DAY_THREE']
  161. DateEpochMs: int
  162. High: float
  163. Low: float
  164. Volume: float
  165. Traded: int
  166. @dataclasses.dataclass(eq=False, slots=True)
  167. class AskFilled:
  168. price: float
  169. amount: int
  170. time: int
  171. @dataclasses.dataclass(eq=False, slots=True)
  172. class ChartAnalysis:
  173. bids_filled: float
  174. asks_filled: float
  175. profits: float
  176. p75_fill_time: datetime.timedelta
  177. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  178. class Market:
  179. exchange_code: str
  180. ticker: str
  181. bid: float
  182. ask: float
  183. spread: float
  184. chart_analysis: ChartAnalysis
  185. def __lt__(self, o: Market) -> bool:
  186. return self.chart_analysis.profits < o.chart_analysis.profits
  187. if __name__ == '__main__':
  188. main()