market.py 9.5 KB

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