diff --git a/meta/meta.json b/meta/meta.json index dee81d45f..93596ee9e 100644 --- a/meta/meta.json +++ b/meta/meta.json @@ -1,3 +1,3 @@ { - "subnet_version": "6.1.0" + "subnet_version": "6.1.1" } diff --git a/neurons/validator.py b/neurons/validator.py index c2ebb711d..b8ba4fde5 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -761,8 +761,8 @@ def receive_signal(self, synapse: template.protocol.SendSignal, order.slippage = PriceSlippageModel.calculate_slippage(order.bid, order.ask, order) self._enforce_num_open_order_limit(trade_pair_to_open_position, order) self.enforce_order_cooldown(order, existing_position) - net_portfolio_leverage = self.position_manager.calculate_net_portfolio_leverage(miner_hotkey) - existing_position.add_order(order, net_portfolio_leverage) + net_portfolio_leverage, net_currency_leverage = self.position_manager.calculate_net_portfolio_leverage(miner_hotkey) + existing_position.add_order(order, net_portfolio_leverage, net_currency_leverage) self.position_manager.save_miner_position(existing_position) synapse.order_json = order.__str__() if miner_order_uuid: diff --git a/tests/vali_tests/test_positions.py b/tests/vali_tests/test_positions.py index d3ecf1499..be574a6ea 100644 --- a/tests/vali_tests/test_positions.py +++ b/tests/vali_tests/test_positions.py @@ -43,7 +43,8 @@ def setUp(self): self.position_manager.clear_all_miner_positions() def add_order_to_position_and_save(self, position, order): - position.add_order(order, self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY)) + net_portfolio_leverage, net_currency_leverage = self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY) + position.add_order(order, net_portfolio_leverage, net_currency_leverage) self.position_manager.save_miner_position(position) def _find_disk_position_from_memory_position(self, position): @@ -2196,7 +2197,7 @@ def test_long_order_leverage_already_at_portfolio_limit(self): order_uuid="1000") self.add_order_to_position_and_save(position2, o2) self.position_manager.save_miner_position(position2) - self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY), 10.0) + self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY)[0], 10.0) position3 = deepcopy(self.default_position) position3.trade_pair = TradePair.ETHUSD @@ -2238,7 +2239,7 @@ def test_long_order_crypto_leverage_exceed_portfolio_limit(self): self.add_order_to_position_and_save(position2, o2) self.position_manager.save_miner_position(position2) - self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY), 9.0) + self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY)[0], 9.0) position3 = deepcopy(self.default_position) position3.trade_pair=TradePair.ETHUSD @@ -2282,7 +2283,7 @@ def test_long_order_forex_leverage_exceed_portfolio_limit(self): order_uuid="1000") self.add_order_to_position_and_save(position2, o2) self.position_manager.save_miner_position(position2) - self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY), 9.0) + self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY)[0], 9.0) position3 = deepcopy(self.default_position) position3.trade_pair=TradePair.AUDJPY @@ -2324,7 +2325,7 @@ def test_short_order_leverage_exceed_portfolio_limit(self): processed_ms=leverage_utils.PORTFOLIO_LEVERAGE_BOUNDS_START_TIME_MS + 1000, order_uuid="1000") self.add_order_to_position_and_save(position2, o2) - self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY), 9.0) + self.assertEqual(self.position_manager.calculate_net_portfolio_leverage(self.DEFAULT_MINER_HOTKEY)[0], 9.0) position3 = deepcopy(self.default_position) position3.trade_pair = TradePair.ETHUSD @@ -2380,6 +2381,44 @@ def test_position_below_min_while_portfolio_lev_exceeded(self): # the o3 order should be skipped since it would bring the position net leverage below min leverage. self.assertEqual(position2.net_leverage, 50) + def test_currency_net_leverage_exceeded(self): + """ + a fx position which brings one of either base or quote currencies above the currency net leverage limit + should not be allowed + """ + position1 = deepcopy(self.default_position) + position1.trade_pair = TradePair.USDJPY + o1 = Order(order_type=OrderType.LONG, + leverage=5, + price=100, + trade_pair=TradePair.USDJPY, + processed_ms=leverage_utils.CURRENCY_NET_LEVERAGE_BOUNDS_START_TIME_MS + 1000, + order_uuid="1000") + self.add_order_to_position_and_save(position1, o1) + + position2 = deepcopy(self.default_position) + position2.trade_pair = TradePair.EURAUD + position2.position_uuid = self.DEFAULT_POSITION_UUID + "_2" + o2 = Order(order_type=OrderType.LONG, + leverage=1, + price=100, + trade_pair=TradePair.EURAUD, + processed_ms=leverage_utils.CURRENCY_NET_LEVERAGE_BOUNDS_START_TIME_MS + 1000, + order_uuid="1001") + self.add_order_to_position_and_save(position2, o2) + + position3 = deepcopy(self.default_position) + position3.trade_pair = TradePair.USDCAD + position3.position_uuid = self.DEFAULT_POSITION_UUID + "_3" + o3 = Order(order_type=OrderType.LONG, + leverage=1, + price=100, + trade_pair=TradePair.USDCAD, + processed_ms=leverage_utils.CURRENCY_NET_LEVERAGE_BOUNDS_START_TIME_MS + 1000, + order_uuid="1002") + with self.assertRaises(ValueError): + # the o3 order should be skipped since it would bring the currency net leverage above the cap. + self.add_order_to_position_and_save(position3, o3) def test_position_json(self): position = deepcopy(self.default_position) diff --git a/vali_objects/position.py b/vali_objects/position.py index 90b83d9fa..b1a02ee7d 100644 --- a/vali_objects/position.py +++ b/vali_objects/position.py @@ -338,7 +338,7 @@ def log_position_status(self): ] bt.logging.debug(f"position order details: " f"close_ms [{order_info}] ") - def add_order(self, order: Order, net_portfolio_leverage: float=0.0) -> bool: + def add_order(self, order: Order, net_portfolio_leverage: float=0.0, net_currency_leverage: dict=None): """ Add an order to a position, and adjust its leverage to stay within the trade pair max and portfolio max. @@ -349,7 +349,10 @@ def add_order(self, order: Order, net_portfolio_leverage: float=0.0) -> bool: raise ValueError( f"Order trade pair [{order.trade_pair}] does not match position trade pair [{self.trade_pair}]") - if self._clamp_and_validate_leverage(order, abs(net_portfolio_leverage)): + if net_currency_leverage is None: + net_currency_leverage = {} + + if self._clamp_and_validate_leverage(order, abs(net_portfolio_leverage), net_currency_leverage): # This order's leverage got clamped to zero. # Skip it since we don't want to consider this a FLAT position and we don't want to allow bad actors # to send in a bunch of spam orders. @@ -601,7 +604,7 @@ def reopen_position(self): self.is_closed_position = False self.close_ms = None - def _clamp_and_validate_leverage(self, order: Order, net_portfolio_leverage: float) -> bool: + def _clamp_and_validate_leverage(self, order: Order, net_portfolio_leverage: float, net_currency_leverage: dict) -> bool: """ If an order's leverage would make the position's leverage higher than max_position_leverage, we clamp the order's leverage. If clamping causes the order's leverage to be below @@ -609,6 +612,8 @@ def _clamp_and_validate_leverage(self, order: Order, net_portfolio_leverage: flo If an order's leverage would take the position leverage below min_position_leverage, we raise an error. + For FX, order leverage must also stay within net currency leverage exposure limits. + Return true if the order should be ignored. Only happens when the order attempts to exceed max_position_leverage and is already at max_position_leverage. """ @@ -626,6 +631,15 @@ def _clamp_and_validate_leverage(self, order: Order, net_portfolio_leverage: flo (abs(proposed_leverage) * self.trade_pair.leverage_multiplier)) max_portfolio_leverage = leverage_utils.get_portfolio_leverage_cap(order.processed_ms) + if order.trade_pair.is_forex: + proposed_base_currency_leverage = net_currency_leverage.get(order.trade_pair.base, 0) + order.leverage + proposed_quote_currency_leverage = net_currency_leverage.get(order.trade_pair.quote, 0) - order.leverage + max_currency_leverage = leverage_utils.get_currency_net_leverage_cap(order.processed_ms, order.trade_pair) + if abs(proposed_base_currency_leverage) > max_currency_leverage: + raise ValueError(f"Miner {self.miner_hotkey} attempted to exceed {self.trade_pair.base} currency leverage exposure. Current exposure: {net_currency_leverage.get(order.trade_pair.base)}") + if abs(proposed_quote_currency_leverage) > max_currency_leverage: + raise ValueError(f"Miner {self.miner_hotkey} attempted to exceed {self.trade_pair.quote} currency leverage exposure. Current exposure: {net_currency_leverage.get(order.trade_pair.quote)}") + # we only need to worry about clamping if the sign of the position leverage remains the same i.e. position does not flip and close if is_first_order or self.net_leverage * proposed_leverage > 0: if (abs(proposed_leverage) > max_position_leverage diff --git a/vali_objects/utils/leverage_utils.py b/vali_objects/utils/leverage_utils.py index 4345d0d26..1bfd7795f 100644 --- a/vali_objects/utils/leverage_utils.py +++ b/vali_objects/utils/leverage_utils.py @@ -3,6 +3,7 @@ PORTFOLIO_LEVERAGE_BOUNDS_START_TIME_MS = 1727161200000 INDICES_SOFT_CUTOFF_MS = 1731700800000 EQUITIES_METALS_SOFT_CUTOFF_MS = 1744700399000 +CURRENCY_NET_LEVERAGE_BOUNDS_START_TIME_MS = 1747551600000 from vali_objects.vali_config import TradePair, ValiConfig # noqa: E402 @@ -40,6 +41,13 @@ def get_position_leverage_bounds(trade_pair: TradePair, t_ms: int) -> (float, fl return min_position_leverage, max_position_leverage def get_portfolio_leverage_cap(t_ms: int) -> float: - is_portfolio_cap= t_ms >= PORTFOLIO_LEVERAGE_BOUNDS_START_TIME_MS + is_portfolio_cap = t_ms >= PORTFOLIO_LEVERAGE_BOUNDS_START_TIME_MS max_portfolio_leverage = ValiConfig.PORTFOLIO_LEVERAGE_CAP if is_portfolio_cap else float('inf') return max_portfolio_leverage + +def get_currency_net_leverage_cap(t_ms: int, trade_pair: TradePair) -> float: + if not trade_pair.is_forex: + return float('inf') + is_currency_cap = t_ms >= CURRENCY_NET_LEVERAGE_BOUNDS_START_TIME_MS + max_currency_leverage = ValiConfig.CURRENCY_NET_LEVERAGE_CAP if is_currency_cap else float('inf') + return max_currency_leverage diff --git a/vali_objects/utils/position_manager.py b/vali_objects/utils/position_manager.py index 7e10609e1..6ec064d7d 100644 --- a/vali_objects/utils/position_manager.py +++ b/vali_objects/utils/position_manager.py @@ -931,18 +931,22 @@ def _delete_position_from_memory(self, hotkey, position_uuid): else: del self.hotkey_to_positions[hotkey] - def calculate_net_portfolio_leverage(self, hotkey: str) -> float: + def calculate_net_portfolio_leverage(self, hotkey: str) -> (float, dict): """ Calculate leverage across all open positions Normalize each asset class with a multiplier """ positions = self.get_positions_for_one_hotkey(hotkey, only_open_positions=True) + currency_net_leverage = defaultdict(float) portfolio_leverage = 0.0 for position in positions: portfolio_leverage += abs(position.get_net_leverage()) * position.trade_pair.leverage_multiplier + if position.trade_pair.is_forex: + currency_net_leverage[position.trade_pair.base] += position.get_net_leverage() + currency_net_leverage[position.trade_pair.quote] -= position.get_net_leverage() - return portfolio_leverage + return portfolio_leverage, currency_net_leverage @timeme def get_positions_for_all_miners(self, from_disk=False, **args): diff --git a/vali_objects/vali_config.py b/vali_objects/vali_config.py index 4def72c6d..7f4d70f3c 100644 --- a/vali_objects/vali_config.py +++ b/vali_objects/vali_config.py @@ -66,6 +66,10 @@ class ValiConfig: EQUITIES_MIN_LEVERAGE = 0.1 EQUITIES_MAX_LEVERAGE = 3 + # Cap leverage across miner's entire portfolio + PORTFOLIO_LEVERAGE_CAP = 10 + CURRENCY_NET_LEVERAGE_CAP = 5 + CAPITAL = 100_000 # conversion of 1x leverage to $100K in capital MAX_DAILY_DRAWDOWN = 0.95 # Portfolio should never fall below .95 x of initial value when measured day to day @@ -161,9 +165,6 @@ class ValiConfig: # Require at least this many successful checkpoints before building golden MIN_CHECKPOINTS_RECEIVED = 5 - # Cap leverage across miner's entire portfolio - PORTFOLIO_LEVERAGE_CAP = 10 - assert ValiConfig.CRYPTO_MIN_LEVERAGE >= ValiConfig.ORDER_MIN_LEVERAGE assert ValiConfig.CRYPTO_MAX_LEVERAGE <= ValiConfig.ORDER_MAX_LEVERAGE assert ValiConfig.FOREX_MIN_LEVERAGE >= ValiConfig.ORDER_MIN_LEVERAGE @@ -322,6 +323,16 @@ def leverage_multiplier(self) -> int: TradePairCategory.EQUITIES: 2} return trade_pair_leverage_multiplier[self.trade_pair_category] + @property + def base(self): + if self.is_forex: + return self.trade_pair.split("/")[0] + + @property + def quote(self): + if self.is_forex: + return self.trade_pair.split("/")[1] + @classmethod def categories(cls): return {tp.trade_pair_id: tp.trade_pair_category.value for tp in cls}