market.py 7.7 KB

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