market.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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 0
  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 = 0
  108. if lowest_bid > 0:
  109. score = (lowest_bid - (ic1_price['Bid'] or 0)) / lowest_bid * 100
  110. if highest_ask > 0:
  111. score += ((ic1_price['Ask'] or 10_000_000) - highest_ask) / highest_ask * 100
  112. if score > 0 and (trade_activity_deficit := lowest_traded - (ic1_price['Traded30D'] or 0)) > 0:
  113. score *= trade_activity_deficit
  114. if score > 5:
  115. yield MarketHealth(ticker=ticker, score=score, lowest_bid=lowest_bid, my_bid=bids.get(ticker, 0))
  116. def analyze_raw_price(price: RawPrice) -> Market | None:
  117. if (traded := price['AverageTraded7D']) is None or traded < 100:
  118. return
  119. if price['Bid'] is None or price['Ask'] is None:
  120. return
  121. if (high := price['HighYesterday']) is None or (low := price['LowYesterday']) is None:
  122. return
  123. if (high - low) / high < 0.1:
  124. return
  125. spread = (price['Ask'] - price['Bid']) / price['Ask']
  126. if (spread < 0.25 and price['ExchangeCode'] != 'IC1') or (spread < 0.15 and price['ExchangeCode'] == 'IC1'):
  127. return
  128. chart_analysis = analyze_price_chart(price['FullTicker'], (price['Bid'] + price['Ask']) / 2)
  129. return Market(price['ExchangeCode'], price['MaterialTicker'], bid=price['Bid'], ask=price['Ask'],
  130. spread=spread, chart_analysis=chart_analysis)
  131. def analyze_price_chart(exchange_ticker: str, midpoint: float) -> ChartAnalysis:
  132. '''use price chart to estimate how long it takes to fill a bid and then an ask'''
  133. pcpoints: list[PriceChartPoint] = [p for p in cache.get('https://rest.fnar.net/exchange/cxpc/' + exchange_ticker)
  134. if p['Interval'] == 'MINUTE_FIVE']
  135. pcpoints.reverse()
  136. five_min = 5 * 60 * 1000
  137. cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=30)
  138. asks_filled: list[AskFilled] = []
  139. fill_time = []
  140. r = ChartAnalysis(bids_filled=0, asks_filled=0, profits=0, p75_fill_time=datetime.timedelta.max)
  141. for hist in pcpoints:
  142. if datetime.datetime.fromtimestamp(hist['DateEpochMs'] // 1000, datetime.UTC) < cutoff:
  143. continue
  144. time = hist['DateEpochMs'] // five_min
  145. bids = asks = 0
  146. if hist['Low'] > midpoint:
  147. asks = hist['Traded']
  148. r.asks_filled += asks
  149. elif hist['High'] < midpoint:
  150. bids = hist['Traded']
  151. r.bids_filled += bids
  152. elif hist['High'] == hist['Low']: # all trades right at midpoint
  153. assert hist['High'] == midpoint
  154. else:
  155. interval_bids = (hist['High'] * hist['Traded'] - hist['Volume']) / (hist['High'] - hist['Low'])
  156. interval_asks = hist['Traded'] - interval_bids
  157. r.bids_filled += interval_bids
  158. r.asks_filled += interval_asks
  159. bids = int(interval_bids)
  160. asks = int(interval_asks)
  161. if bids and asks:
  162. intra_interval_trades = min(bids, asks)
  163. r.profits += (hist['High'] - hist['Low']) * intra_interval_trades
  164. bids -= intra_interval_trades
  165. asks -= intra_interval_trades
  166. assert bids == 0 or asks == 0
  167. while bids > 0 and len(asks_filled) > 0:
  168. profit = (asks_filled[-1].price - hist['Low'])
  169. if asks_filled[-1].amount >= bids:
  170. r.profits += bids * profit
  171. asks_filled[-1].amount -= bids
  172. if asks_filled[-1].amount == 0:
  173. fill_time.append(asks_filled[-1].time - time)
  174. asks_filled.pop()
  175. bids = 0
  176. else:
  177. r.profits += asks_filled[-1].amount * profit
  178. bids -= asks_filled[-1].amount
  179. fill_time.append(asks_filled[-1].time - time)
  180. asks_filled.pop()
  181. if asks:
  182. asks_filled.append(AskFilled(price=hist['High'], amount=asks, time=time))
  183. if len(fill_time) > 0:
  184. r.p75_fill_time = statistics.quantiles(fill_time, n=4)[2] * datetime.timedelta(minutes=5)
  185. return r
  186. def format_td(td: datetime.timedelta) -> str:
  187. if td == datetime.timedelta.max:
  188. return '∞'
  189. days, seconds = divmod(td.total_seconds(), 24 * 60 * 60)
  190. hours = seconds / (60 * 60)
  191. return f'{int(days)}d {hours:4.1f}h'
  192. class ExchangeOrder(typing.TypedDict):
  193. MaterialTicker: str
  194. ExchangeCode: str
  195. OrderType: typing.Literal['SELLING', 'BUYING']
  196. Status: typing.Literal['FILLED', 'PARTIALLY_FILLED']
  197. Amount: int
  198. Limit: float
  199. Trades: typing.Sequence[ExchangeTrade]
  200. class ExchangeTrade(typing.TypedDict):
  201. TradeTimeEpochMs: int
  202. Amount: int
  203. Price: float
  204. PartnerName: str
  205. class ExchangeSummary(typing.TypedDict):
  206. MaterialTicker: str
  207. ExchangeCode: str
  208. Bid: float | None
  209. Ask: float | None
  210. class Warehouse(typing.TypedDict):
  211. StoreId: str
  212. LocationNaturalId: str
  213. class Storage(typing.TypedDict):
  214. Name: str
  215. StorageItems: typing.Sequence[StorageItem]
  216. WeightLoad: float
  217. VolumeLoad: float
  218. Type: typing.Literal['STORE', 'WAREHOUSE_STORE', 'FTL_FUEL_STORE', 'STL_FUEL_STORE', 'SHIP_STORE']
  219. class StorageItem(typing.TypedDict):
  220. MaterialTicker: str | None # shipment blocks are None
  221. MaterialAmount: int
  222. Type: typing.Literal['INVENTORY', 'SHIPMENT']
  223. class RawPrice(typing.TypedDict):
  224. FullTicker: str
  225. MaterialTicker: str
  226. ExchangeCode: str
  227. Bid: float | None
  228. Ask: float | None
  229. HighYesterday: float | None
  230. LowYesterday: float | None
  231. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  232. Traded7D: int | None
  233. Traded30D: int | None
  234. class PriceChartPoint(typing.TypedDict):
  235. Interval: typing.Literal['MINUTE_FIVE', 'MINUTE_FIFTEEN', 'MINUTE_THIRTY', 'HOUR_ONE', 'HOUR_TWO', 'HOUR_FOUR', 'HOUR_SIX', 'HOUR_TWELVE', 'DAY_ONE', 'DAY_THREE']
  236. DateEpochMs: int
  237. High: float
  238. Low: float
  239. Volume: float
  240. Traded: int
  241. @dataclasses.dataclass(eq=False, slots=True)
  242. class MarketHealth:
  243. ticker: str
  244. score: float
  245. lowest_bid: float
  246. my_bid: float
  247. def __lt__(self, o: MarketHealth) -> bool:
  248. return self.score < o.score
  249. @dataclasses.dataclass(eq=False, slots=True)
  250. class AskFilled:
  251. price: float
  252. amount: int
  253. time: int
  254. @dataclasses.dataclass(eq=False, slots=True)
  255. class ChartAnalysis:
  256. bids_filled: float
  257. asks_filled: float
  258. profits: float
  259. p75_fill_time: datetime.timedelta
  260. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  261. class Market:
  262. exchange_code: str
  263. ticker: str
  264. bid: float
  265. ask: float
  266. spread: float
  267. chart_analysis: ChartAnalysis
  268. def __lt__(self, o: Market) -> bool:
  269. return self.chart_analysis.profits < o.chart_analysis.profits
  270. if __name__ == '__main__':
  271. main()