market.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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(f'{ticker:3}: {score:10.1f}')
  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('\n 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 = (lowest_bid - (ic1_price['Bid'] or 0)) / lowest_bid * 100
  76. if highest_ask > 0:
  77. score += ((ic1_price['Ask'] or 10_000_000) - highest_ask) / highest_ask * 100
  78. if score > 0 and (trade_activity_deficit := lowest_traded - (ic1_price['Traded30D'] or 0)) > 0:
  79. score *= trade_activity_deficit
  80. if score > 5:
  81. yield score, ticker
  82. def analyze_raw_price(price: RawPrice) -> Market | None:
  83. if (traded := price['AverageTraded7D']) is None or traded < 100:
  84. return
  85. if price['Bid'] is None or price['Ask'] is None:
  86. return
  87. if (high := price['HighYesterday']) is None or (low := price['LowYesterday']) is None:
  88. return
  89. if (high - low) / high < 0.1:
  90. return
  91. spread = (price['Ask'] - price['Bid']) / price['Ask']
  92. if spread < 0.25:
  93. return
  94. chart_analysis = analyze_price_chart(price['FullTicker'], (price['Bid'] + price['Ask']) / 2)
  95. return Market(price['ExchangeCode'], price['MaterialTicker'], bid=price['Bid'], ask=price['Ask'],
  96. spread=spread, chart_analysis=chart_analysis)
  97. def analyze_price_chart(exchange_ticker: str, midpoint: float) -> ChartAnalysis:
  98. '''use price chart to estimate how long it takes to fill a bid and then an ask'''
  99. pcpoints: list[PriceChartPoint] = [p for p in cache.get('https://rest.fnar.net/exchange/cxpc/' + exchange_ticker)
  100. if p['Interval'] == 'MINUTE_FIVE']
  101. pcpoints.reverse()
  102. five_min = 5 * 60 * 1000
  103. cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=30)
  104. asks_filled: list[AskFilled] = []
  105. fill_time = []
  106. r = ChartAnalysis(bids_filled=0, asks_filled=0, profits=0, p75_fill_time=datetime.timedelta.max)
  107. for hist in pcpoints:
  108. if datetime.datetime.fromtimestamp(hist['DateEpochMs'] // 1000, datetime.UTC) < cutoff:
  109. continue
  110. time = hist['DateEpochMs'] // five_min
  111. bids = asks = 0
  112. if hist['Low'] > midpoint:
  113. asks = hist['Traded']
  114. r.asks_filled += asks
  115. elif hist['High'] < midpoint:
  116. bids = hist['Traded']
  117. r.bids_filled += bids
  118. elif hist['High'] == hist['Low']: # all trades right at midpoint
  119. assert hist['High'] == midpoint
  120. else:
  121. interval_bids = (hist['High'] * hist['Traded'] - hist['Volume']) / (hist['High'] - hist['Low'])
  122. interval_asks = hist['Traded'] - interval_bids
  123. r.bids_filled += interval_bids
  124. r.asks_filled += interval_asks
  125. bids = int(interval_bids)
  126. asks = int(interval_asks)
  127. if bids and asks:
  128. intra_interval_trades = min(bids, asks)
  129. r.profits += (hist['High'] - hist['Low']) * intra_interval_trades
  130. bids -= intra_interval_trades
  131. asks -= intra_interval_trades
  132. assert bids == 0 or asks == 0
  133. while bids > 0 and len(asks_filled) > 0:
  134. profit = (asks_filled[-1].price - hist['Low'])
  135. if asks_filled[-1].amount >= bids:
  136. r.profits += bids * profit
  137. asks_filled[-1].amount -= bids
  138. if asks_filled[-1].amount == 0:
  139. fill_time.append(asks_filled[-1].time - time)
  140. asks_filled.pop()
  141. bids = 0
  142. else:
  143. r.profits += asks_filled[-1].amount * profit
  144. bids -= asks_filled[-1].amount
  145. fill_time.append(asks_filled[-1].time - time)
  146. asks_filled.pop()
  147. if asks:
  148. asks_filled.append(AskFilled(price=hist['High'], amount=asks, time=time))
  149. if len(fill_time) > 0:
  150. r.p75_fill_time = statistics.quantiles(fill_time, n=4)[2] * datetime.timedelta(minutes=5)
  151. return r
  152. def format_td(td: datetime.timedelta) -> str:
  153. if td == datetime.timedelta.max:
  154. return '∞'
  155. days, seconds = divmod(td.total_seconds(), 24 * 60 * 60)
  156. hours = seconds / (60 * 60)
  157. return f'{int(days)}d {hours:4.1f}h'
  158. class ExchangeOrder(typing.TypedDict):
  159. MaterialTicker: str
  160. ExchangeCode: str
  161. OrderType: typing.Literal['SELLING', 'BUYING']
  162. Status: typing.Literal['FILLED', 'PARTIALLY_FILLED']
  163. Amount: int
  164. Limit: float
  165. class ExchangeSummary(typing.TypedDict):
  166. MaterialTicker: str
  167. ExchangeCode: str
  168. Bid: float | None
  169. Ask: float | None
  170. class Warehouse(typing.TypedDict):
  171. StoreId: str
  172. LocationNaturalId: str
  173. class Storage(typing.TypedDict):
  174. Name: str
  175. StorageItems: typing.Sequence[StorageItem]
  176. WeightLoad: float
  177. VolumeLoad: float
  178. Type: typing.Literal['STORE', 'WAREHOUSE_STORE', 'FTL_FUEL_STORE', 'STL_FUEL_STORE', 'SHIP_STORE']
  179. class StorageItem(typing.TypedDict):
  180. MaterialTicker: str | None # shipment blocks are None
  181. MaterialAmount: int
  182. Type: typing.Literal['INVENTORY', 'SHIPMENT']
  183. class RawPrice(typing.TypedDict):
  184. FullTicker: str
  185. MaterialTicker: str
  186. ExchangeCode: str
  187. Bid: float | None
  188. Ask: float | None
  189. HighYesterday: float | None
  190. LowYesterday: float | None
  191. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  192. Traded30D: int | None
  193. class PriceChartPoint(typing.TypedDict):
  194. Interval: typing.Literal['MINUTE_FIVE', 'MINUTE_FIFTEEN', 'MINUTE_THIRTY', 'HOUR_ONE', 'HOUR_TWO', 'HOUR_FOUR', 'HOUR_SIX', 'HOUR_TWELVE', 'DAY_ONE', 'DAY_THREE']
  195. DateEpochMs: int
  196. High: float
  197. Low: float
  198. Volume: float
  199. Traded: int
  200. @dataclasses.dataclass(eq=False, slots=True)
  201. class AskFilled:
  202. price: float
  203. amount: int
  204. time: int
  205. @dataclasses.dataclass(eq=False, slots=True)
  206. class ChartAnalysis:
  207. bids_filled: float
  208. asks_filled: float
  209. profits: float
  210. p75_fill_time: datetime.timedelta
  211. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  212. class Market:
  213. exchange_code: str
  214. ticker: str
  215. bid: float
  216. ask: float
  217. spread: float
  218. chart_analysis: ChartAnalysis
  219. def __lt__(self, o: Market) -> bool:
  220. return self.chart_analysis.profits < o.chart_analysis.profits
  221. if __name__ == '__main__':
  222. main()