market.py 8.5 KB

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