market.py 7.4 KB

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