market.py 8.1 KB

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