market.py 7.3 KB

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