market.py 11 KB

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