market.py 11 KB

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