diff --git a/data_generator/tiingo_data_service.py b/data_generator/tiingo_data_service.py index 2f2cb22d7..ad54ae03d 100644 --- a/data_generator/tiingo_data_service.py +++ b/data_generator/tiingo_data_service.py @@ -37,7 +37,7 @@ async def connect(self, handle_msg): """ Asynchronous connect method for the Tiingo websocket client """ - POLLING_INTERVAL_S = 5 + POLLING_INTERVAL_S = 8.6 last_poll_time = 0 while not self._should_close: diff --git a/docs/miner.md b/docs/miner.md index 2086d8ab9..ebc6b9d1f 100644 --- a/docs/miner.md +++ b/docs/miner.md @@ -110,12 +110,12 @@ $$ | Metric | Scoring Weight | |------------------------|----------------| -| Average Daily PnL | 90% | -| Calmar Ratio | 2% | -| Sharpe Ratio | 2% | -| Omega Ratio | 2% | -| Sortino Ratio | 2% | -| Statistical Confidence | 2% | +| Average Daily PnL | 100% | +| Calmar Ratio | 0% | +| Sharpe Ratio | 0% | +| Omega Ratio | 0% | +| Sortino Ratio | 0% | +| Statistical Confidence | 0% | ### Scoring Penalties diff --git a/meta/meta.json b/meta/meta.json index d4d42932e..7390aae7b 100644 --- a/meta/meta.json +++ b/meta/meta.json @@ -1,3 +1,3 @@ { - "subnet_version": "8.8.8" + "subnet_version": "8.8.9" } diff --git a/tests/vali_tests/test_challengeperiod_unit.py b/tests/vali_tests/test_challengeperiod_unit.py index 6c6d72915..1d3e1b4c6 100644 --- a/tests/vali_tests/test_challengeperiod_unit.py +++ b/tests/vali_tests/test_challengeperiod_unit.py @@ -14,7 +14,6 @@ from tests.vali_tests.base_objects.test_base import TestBase from vali_objects.enums.order_type_enum import OrderType from vali_objects.vali_dataclasses.position import Position -from vali_objects.scoring.scoring import Scoring from vali_objects.challenge_period import ChallengePeriodManager from vali_objects.vali_dataclasses.ledger.ledger_utils import LedgerUtils from vali_objects.enums.miner_bucket_enum import MinerBucket @@ -197,35 +196,23 @@ def save_and_get_positions(self, base_positions, hotkeys): # Re-raise to preserve original test failure behavior raise - def get_combined_scores_dict(self, miner_scores: dict[str, float], asset_class=None): + def get_asset_softmaxed_scores(self, miner_scores: dict[str, float], asset_class=None): """ - Create a combined scores dict for testing. + Create asset_softmaxed_scores dict for testing. Args: miner_scores: dict mapping hotkey to score (0.0 to 1.0) asset_class: TradePairCategory, defaults to CRYPTO Returns: - combined_scores_dict in the format expected by inspect() + asset_softmaxed_scores in the format expected by inspect() + Format: {TradePairCategory: {hotkey: score, ...}} """ if asset_class is None: asset_class = vali_file.TradePairCategory.CRYPTO - combined_scores_dict = {asset_class: {"metrics": {}, "penalties": {}}} - asset_class_dict = combined_scores_dict[asset_class] - - # Create scores for each metric - for config_name, config in Scoring.scoring_config.items(): - scores_list = [(hotkey, score) for hotkey, score in miner_scores.items()] - asset_class_dict["metrics"][config_name] = { - 'scores': scores_list, - 'weight': config['weight'] - } - - # All miners get penalty multiplier of 1 (no penalty) - asset_class_dict["penalties"] = {hotkey: 1.0 for hotkey in miner_scores.keys()} - - return combined_scores_dict + # asset_softmaxed_scores format: {asset_class: {hotkey: score}} + return {asset_class: miner_scores} def _populate_active_miners(self, *, maincomp=[], challenge=[], probation=[]): """Populate active miners using RPC client methods with error handling.""" @@ -309,7 +296,7 @@ def test_failing_remaining_time(self): # Top 25 success miners get scores from 1.0 down to 0.76 (25 miners) miner_scores[self.SUCCESS_MINER_NAMES[i]] = 1.0 - (i * 0.01) - combined_scores_dict = self.get_combined_scores_dict(miner_scores) + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) # Check that the miner continues in challenge (time remaining, so not eliminated) passing, demoted, failing = self.challenge_period_client.inspect( @@ -320,7 +307,7 @@ def test_failing_remaining_time(self): inspection_hotkeys={"miner": current_time}, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, - combined_scores_dict=combined_scores_dict, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertNotIn("miner", passing) self.assertNotIn("miner", list(failing.keys())) @@ -367,6 +354,10 @@ def test_passing_remaining_time(self): inspection_hotkeys = {"miner": self.START_TIME} current_time = self.CURRENTLY_IN_CHALLENGE + # Create scores where miner is in top 25 (passing) + miner_scores = {"miner": 1.0} + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) + # Check that the miner is screened as passing passing, demoted, failing = self.challenge_period_client.inspect( positions=inspection_positions, @@ -376,6 +367,7 @@ def test_passing_remaining_time(self): inspection_hotkeys=inspection_hotkeys, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertIn("miner", passing) @@ -395,6 +387,10 @@ def test_passing_no_remaining_time(self): inspection_hotkeys = {"miner": self.START_TIME} current_time = self.CURRENTLY_IN_CHALLENGE + # Create scores where miner is in top 25 (passing) + miner_scores = {"miner": 1.0} + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) + # Check that the miner is screened as passing passing, demoted, failing = self.challenge_period_client.inspect( positions=inspection_positions, @@ -404,6 +400,7 @@ def test_passing_no_remaining_time(self): inspection_hotkeys=inspection_hotkeys, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertIn("miner", passing) @@ -499,7 +496,7 @@ def test_just_above_threshold(self): miner_scores[self.SUCCESS_MINER_NAMES[23]] = 0.76 miner_scores[self.SUCCESS_MINER_NAMES[24]] = 0.75 - combined_scores_dict = self.get_combined_scores_dict(miner_scores) + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) # Check that the miner is promoted (in top 25) passing, demoted, failing = self.challenge_period_client.inspect( @@ -510,7 +507,7 @@ def test_just_above_threshold(self): inspection_hotkeys={"miner": current_time}, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, - combined_scores_dict=combined_scores_dict, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertIn("miner", passing) self.assertNotIn("miner", list(failing.keys())) @@ -539,7 +536,7 @@ def test_just_below_threshold(self): miner_scores["miner"] = 0.74 # Rank 26 (just below rank 25's score of 0.76) - combined_scores_dict = self.get_combined_scores_dict(miner_scores) + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) # Check that the miner continues in challenge (not promoted, not eliminated) passing, demoted, failing = self.challenge_period_client.inspect( @@ -550,7 +547,7 @@ def test_just_below_threshold(self): inspection_hotkeys={"miner": current_time}, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, - combined_scores_dict=combined_scores_dict, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertNotIn("miner", passing) self.assertNotIn("miner", list(failing.keys())) @@ -578,7 +575,7 @@ def test_at_threshold(self): miner_scores["miner"] = 0.76 # Ties for rank 25 miner_scores[self.SUCCESS_MINER_NAMES[24]] = 0.75 # Rank 26, will be demoted - combined_scores_dict = self.get_combined_scores_dict(miner_scores) + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) # Check that the miner is promoted (at threshold rank 25) passing, demoted, failing = self.challenge_period_client.inspect( @@ -589,7 +586,7 @@ def test_at_threshold(self): inspection_hotkeys={"miner": current_time}, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, - combined_scores_dict=combined_scores_dict, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertIn("miner", passing) @@ -621,6 +618,10 @@ def test_screen_minimum_interaction(self): portfolio_cps = [cp for cp in base_ledger_portfolio.cps if cp.last_update_ms < current_time] base_ledger_portfolio.cps = portfolio_cps + # Create scores where miner is in top 25 (passing) + miner_scores = {"miner": 1.0} + asset_softmaxed_scores = self.get_asset_softmaxed_scores(miner_scores) + # Check that miner with a passing score passes when they have enough trading days passing, demoted, failing = self.challenge_period_client.inspect( positions=inspection_positions, @@ -630,6 +631,7 @@ def test_screen_minimum_interaction(self): inspection_hotkeys={"miner": current_time}, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, + asset_softmaxed_scores=asset_softmaxed_scores, ) self.assertIn("miner", passing) diff --git a/tests/vali_tests/test_limit_orders.py b/tests/vali_tests/test_limit_orders.py index 15f644d83..7b665840a 100644 --- a/tests/vali_tests/test_limit_orders.py +++ b/tests/vali_tests/test_limit_orders.py @@ -113,6 +113,36 @@ def create_test_limit_order(self, order_type: OrderType = OrderType.LONG, limit_ src=OrderSource.LIMIT_UNFILLED ) + def create_filled_market_order(self, order_type: OrderType = OrderType.LONG, fill_price=50000.0, + trade_pair=None, leverage=None, order_uuid=None, + stop_loss=None, take_profit=None, quantity=0.1): + """ + Helper to create a filled market order for testing create_sltp_order. + + This simulates an order that was filled at the market price, which is the + parent order passed to create_sltp_order after a market order completes. + Uses ExecutionType.MARKET to avoid limit order validation. + """ + if trade_pair is None: + trade_pair = self.DEFAULT_TRADE_PAIR + if order_uuid is None: + order_uuid = f"test_market_order_{TimeUtil.now_in_millis()}" + + return Order( + trade_pair=trade_pair, + order_uuid=order_uuid, + processed_ms=TimeUtil.now_in_millis(), + price=fill_price, # The fill price - used for SL/TP validation + order_type=order_type, + leverage=leverage, + quantity=quantity, + execution_type=ExecutionType.MARKET, + limit_price=None, # Market orders don't have limit price + stop_loss=stop_loss, + take_profit=take_profit, + src=OrderSource.ORGANIC # Filled market orders use ORGANIC source + ) + def create_test_price_source(self, price, bid=None, ask=None, start_ms=None): """Helper to create a single price source""" if start_ms is None: @@ -538,7 +568,7 @@ def test_evaluate_trigger_price_flat_long_position(self): price_source, 50000.0 ) - self.assertEqual(trigger, 50000.0) + self.assertIsNone(trigger) def test_evaluate_trigger_price_flat_short_position(self): """Test FLAT order trigger for SHORT position (buys at ask)""" @@ -564,7 +594,7 @@ def test_evaluate_trigger_price_flat_short_position(self): price_source, 50000.0 ) - self.assertEqual(trigger, 50000.0) + self.assertIsNone(trigger) def test_evaluate_trigger_price_fallback_to_open(self): """Test fallback to open price when bid/ask is 0""" @@ -948,8 +978,8 @@ def test_create_bracket_order_with_both_sltp(self): fill_price=50000.0 ) - # Manually call _create_sltp_orders as it's called after fill - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + # Manually call _create_sltp_order as it's called after fill + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) # Verify only ONE bracket order was created orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) @@ -975,7 +1005,7 @@ def test_create_bracket_order_with_only_sl(self): fill_price=50000.0 ) - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) bracket_orders = [o for o in orders if o.order_uuid.endswith('-bracket')] @@ -990,10 +1020,11 @@ def test_create_bracket_order_with_only_tp(self): parent_order = self.create_test_limit_order( limit_price=50000.0, stop_loss=None, - take_profit=51000.0 + take_profit=51000.0, + fill_price=50000.0 ) - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) bracket_orders = [o for o in orders if o.order_uuid.endswith('-bracket')] @@ -1422,7 +1453,7 @@ def test_cancel_bracket_order_using_parent_uuid(self): ) # Manually create bracket order (as would happen after fill) - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) # Verify bracket order exists with correct UUID orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) @@ -1460,7 +1491,7 @@ def test_cancel_bracket_order_using_full_uuid(self): fill_price=50000.0 ) - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) # Cancel using FULL bracket UUID result = self.limit_order_client.cancel_limit_order( @@ -1542,7 +1573,7 @@ def test_bracket_order_uuid_format(self): ) # Create bracket order - self.limit_order_client.create_sltp_orders(self.DEFAULT_MINER_HOTKEY, parent_order) + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) # Verify bracket UUID format orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) @@ -1551,6 +1582,229 @@ def test_bracket_order_uuid_format(self): self.assertEqual(len(bracket_orders), 1) self.assertEqual(bracket_orders[0].order_uuid, expected_bracket_uuid) + # ============================================================================ + # Test: Anti-Gaming Validation for Market Orders with SLTP + # These tests verify that _create_sltp_order properly validates SL/TP against + # the fill price to prevent gaming the system. + # ============================================================================ + + def test_create_sltp_order_long_rejects_sl_above_fill_price(self): + """ + LONG order with stop_loss >= fill_price is rejected with SignalException. + + A miner cannot create a LONG position and set SL above fill price + because that would guarantee the SL triggers immediately for a "free" exit. + """ + # Create a parent order that simulates a filled LONG market order + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=51000.0, # INVALID: Above fill price + take_profit=None + ) + + # Call create_sltp_order - should raise SignalException + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_long_rejects_sl_equal_to_fill_price(self): + """ + LONG order with stop_loss == fill_price is rejected with SignalException. + + Edge case: SL exactly at fill price is also invalid. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=50000.0, # INVALID: Equal to fill price + take_profit=None + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_long_rejects_tp_below_fill_price(self): + """ + LONG order with take_profit <= fill_price is rejected with SignalException. + + A miner cannot create a LONG position and set TP below fill price + because that would guarantee instant profit trigger. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=None, + take_profit=49000.0 # INVALID: Below fill price + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_long_rejects_tp_equal_to_fill_price(self): + """ + LONG order with take_profit == fill_price is rejected with SignalException. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=None, + take_profit=50000.0 # INVALID: Equal to fill price + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_short_rejects_sl_below_fill_price(self): + """ + SHORT order with stop_loss <= fill_price is rejected with SignalException. + + A miner cannot create a SHORT position and set SL below fill price + because that would be an invalid stop loss (SHORT SL triggers when price goes UP). + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.SHORT, + fill_price=50000.0, + stop_loss=49000.0, # INVALID: Below fill price for SHORT + take_profit=None + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_short_rejects_sl_equal_to_fill_price(self): + """ + SHORT order with stop_loss == fill_price is rejected with SignalException. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.SHORT, + fill_price=50000.0, + stop_loss=50000.0, # INVALID: Equal to fill price + take_profit=None + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_short_rejects_tp_above_fill_price(self): + """ + SHORT order with take_profit >= fill_price is rejected with SignalException. + + A miner cannot create a SHORT position and set TP above fill price + because SHORT profits when price goes DOWN, not up. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.SHORT, + fill_price=50000.0, + stop_loss=None, + take_profit=51000.0 # INVALID: Above fill price for SHORT + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_short_rejects_tp_equal_to_fill_price(self): + """ + SHORT order with take_profit == fill_price is rejected with SignalException. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.SHORT, + fill_price=50000.0, + stop_loss=None, + take_profit=50000.0 # INVALID: Equal to fill price + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_long_valid_sl_and_tp_succeeds(self): + """ + Positive test: Valid LONG order with SL < fill_price < TP creates bracket. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=49000.0, # Valid: Below fill price + take_profit=52000.0, # Valid: Above fill price + quantity=0.1 + ) + + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) + bracket_orders = [o for o in orders if o.execution_type == ExecutionType.BRACKET] + self.assertEqual(len(bracket_orders), 1, "Valid LONG bracket order should be created") + self.assertEqual(bracket_orders[0].stop_loss, 49000.0) + self.assertEqual(bracket_orders[0].take_profit, 52000.0) + + def test_create_sltp_order_short_valid_sl_and_tp_succeeds(self): + """ + Positive test: Valid SHORT order with TP < fill_price < SL creates bracket. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.SHORT, + fill_price=50000.0, + stop_loss=51000.0, # Valid: Above fill price (loss for SHORT) + take_profit=48000.0, # Valid: Below fill price (profit for SHORT) + quantity=0.1 + ) + + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) + bracket_orders = [o for o in orders if o.execution_type == ExecutionType.BRACKET] + self.assertEqual(len(bracket_orders), 1, "Valid SHORT bracket order should be created") + self.assertEqual(bracket_orders[0].stop_loss, 51000.0) + self.assertEqual(bracket_orders[0].take_profit, 48000.0) + + def test_create_sltp_order_no_sltp_raises_exception(self): + """ + Test that create_sltp_order with no SL or TP raises SignalException. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=None, + take_profit=None + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_flat_order_rejected(self): + """ + Test that FLAT orders cannot have SLTP brackets and raise SignalException. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.FLAT, + fill_price=50000.0, + stop_loss=49000.0, + take_profit=51000.0 + ) + + with self.assertRaises(SignalException) as context: + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + def test_create_sltp_order_uses_quantity_from_parent(self): + """ + Test that bracket order quantity matches parent order quantity. + + This ensures the bracket order closes the correct amount of the position. + """ + parent_order = self.create_filled_market_order( + order_type=OrderType.LONG, + fill_price=50000.0, + stop_loss=49000.0, + take_profit=52000.0, + quantity=0.5 # 0.5 BTC + ) + + self.limit_order_client.create_sltp_order(self.DEFAULT_MINER_HOTKEY, parent_order) + + orders = self.get_orders_from_server(self.DEFAULT_MINER_HOTKEY, self.DEFAULT_TRADE_PAIR) + bracket_orders = [o for o in orders if o.execution_type == ExecutionType.BRACKET] + self.assertEqual(len(bracket_orders), 1) + self.assertEqual(bracket_orders[0].quantity, 0.5, "Bracket should use parent's quantity") + if __name__ == '__main__': unittest.main() diff --git a/tests/vali_tests/test_order_processor.py b/tests/vali_tests/test_order_processor.py index 86887af75..adc43719c 100644 --- a/tests/vali_tests/test_order_processor.py +++ b/tests/vali_tests/test_order_processor.py @@ -1596,5 +1596,221 @@ def test_order_processing_result_is_frozen(self): result.success = False + # ============================================================================ + # Test: Market Order with SLTP Fields (Integration with create_sltp_order) + # ============================================================================ + + def test_process_order_market_with_sltp_creates_bracket(self): + """ + Test that market order with stop_loss/take_profit creates a bracket order. + + This is the primary integration test for market orders with SLTP fields. + Verifies that after a successful market order fill, create_sltp_order is called. + """ + signal = { + "trade_pair": {"trade_pair_id": "BTCUSD"}, + "execution_type": "MARKET", + "order_type": "LONG", + "leverage": 1.0, + "stop_loss": 49000.0, + "take_profit": 52000.0, + } + + mock_limit_order_client = Mock() + mock_limit_order_client.create_sltp_order = Mock() + + mock_market_order_manager = Mock() + mock_position = Mock() + mock_position.is_closed_position = False + + mock_order = Mock() + mock_order.stop_loss = 49000.0 + mock_order.take_profit = 52000.0 + mock_order.order_type = OrderType.LONG + mock_order.quantity = 0.1 + mock_order.price = 50000.0 + + mock_market_order_manager._process_market_order = Mock( + return_value=("", mock_position, mock_order) + ) + + result = OrderProcessor.process_order( + signal=signal, + miner_order_uuid="test_uuid", + now_ms=self.DEFAULT_NOW_MS, + miner_hotkey=self.DEFAULT_MINER_HOTKEY, + miner_repo_version="1.0.0", + limit_order_client=mock_limit_order_client, + market_order_manager=mock_market_order_manager + ) + + # Verify market order was processed + self.assertEqual(result.execution_type, ExecutionType.MARKET) + mock_market_order_manager._process_market_order.assert_called_once() + + # Verify create_sltp_order was called with correct arguments + mock_limit_order_client.create_sltp_order.assert_called_once_with( + self.DEFAULT_MINER_HOTKEY, mock_order + ) + + def test_process_order_market_with_sltp_closed_position_no_bracket(self): + """ + Test that bracket order is NOT created if position is already closed. + + This tests the race condition protection: if market order closes the position + (e.g., FLAT order), we should not create a bracket order. + """ + signal = { + "trade_pair": {"trade_pair_id": "BTCUSD"}, + "execution_type": "MARKET", + "order_type": "FLAT", + "leverage": 1.0, + "stop_loss": 49000.0, + } + + mock_limit_order_client = Mock() + mock_limit_order_client.create_sltp_order = Mock() + + mock_market_order_manager = Mock() + mock_position = Mock() + mock_position.is_closed_position = True # Position is closed + + mock_order = Mock() + mock_order.stop_loss = 49000.0 + mock_order.take_profit = None + + mock_market_order_manager._process_market_order = Mock( + return_value=("", mock_position, mock_order) + ) + + OrderProcessor.process_order( + signal=signal, + miner_order_uuid="test_uuid", + now_ms=self.DEFAULT_NOW_MS, + miner_hotkey=self.DEFAULT_MINER_HOTKEY, + miner_repo_version="1.0.0", + limit_order_client=mock_limit_order_client, + market_order_manager=mock_market_order_manager + ) + + # Verify create_sltp_order was NOT called (position is closed) + mock_limit_order_client.create_sltp_order.assert_not_called() + + def test_process_order_market_with_sltp_no_position_no_bracket(self): + """ + Test that bracket order is NOT created if no position is returned. + + This handles the edge case where a FLAT order is sent with no existing position. + """ + signal = { + "trade_pair": {"trade_pair_id": "BTCUSD"}, + "execution_type": "MARKET", + "order_type": "FLAT", + "leverage": 1.0, + "stop_loss": 49000.0, + } + + mock_limit_order_client = Mock() + mock_limit_order_client.create_sltp_order = Mock() + + mock_market_order_manager = Mock() + mock_order = Mock() + mock_order.stop_loss = 49000.0 + mock_order.take_profit = None + + # No position returned (e.g., FLAT with no existing position) + mock_market_order_manager._process_market_order = Mock( + return_value=("", None, mock_order) + ) + + OrderProcessor.process_order( + signal=signal, + miner_order_uuid="test_uuid", + now_ms=self.DEFAULT_NOW_MS, + miner_hotkey=self.DEFAULT_MINER_HOTKEY, + miner_repo_version="1.0.0", + limit_order_client=mock_limit_order_client, + market_order_manager=mock_market_order_manager + ) + + # Verify create_sltp_order was NOT called + mock_limit_order_client.create_sltp_order.assert_not_called() + + def test_process_order_market_with_sltp_no_created_order_no_bracket(self): + """ + Test that bracket order is NOT created if no order is returned. + + Handles edge case where market order processing returns no created order. + """ + signal = { + "trade_pair": {"trade_pair_id": "BTCUSD"}, + "execution_type": "MARKET", + "order_type": "LONG", + "leverage": 1.0, + "stop_loss": 49000.0, + } + + mock_limit_order_client = Mock() + mock_limit_order_client.create_sltp_order = Mock() + + mock_market_order_manager = Mock() + mock_position = Mock() + mock_position.is_closed_position = False + + # No order returned + mock_market_order_manager._process_market_order = Mock( + return_value=("", mock_position, None) + ) + + OrderProcessor.process_order( + signal=signal, + miner_order_uuid="test_uuid", + now_ms=self.DEFAULT_NOW_MS, + miner_hotkey=self.DEFAULT_MINER_HOTKEY, + miner_repo_version="1.0.0", + limit_order_client=mock_limit_order_client, + market_order_manager=mock_market_order_manager + ) + + # Verify create_sltp_order was NOT called + mock_limit_order_client.create_sltp_order.assert_not_called() + + def test_process_order_market_with_sltp_error_no_bracket(self): + """ + Test that bracket order is NOT created if market order fails. + + Verifies that SLTP bracket creation only happens on successful market orders. + """ + signal = { + "trade_pair": {"trade_pair_id": "BTCUSD"}, + "execution_type": "MARKET", + "order_type": "LONG", + "leverage": 1.0, + "stop_loss": 49000.0, + } + + mock_limit_order_client = Mock() + mock_limit_order_client.create_sltp_order = Mock() + + mock_market_order_manager = Mock() + mock_market_order_manager._process_market_order = Mock( + return_value=("Order too soon", None, None) + ) + + with self.assertRaises(SignalException): + OrderProcessor.process_order( + signal=signal, + miner_order_uuid="test_uuid", + now_ms=self.DEFAULT_NOW_MS, + miner_hotkey=self.DEFAULT_MINER_HOTKEY, + miner_repo_version="1.0.0", + limit_order_client=mock_limit_order_client, + market_order_manager=mock_market_order_manager + ) + + # Verify create_sltp_order was NOT called + mock_limit_order_client.create_sltp_order.assert_not_called() + + if __name__ == '__main__': unittest.main() diff --git a/vali_objects/challenge_period/challengeperiod_client.py b/vali_objects/challenge_period/challengeperiod_client.py index 3af277319..cce56f16d 100644 --- a/vali_objects/challenge_period/challengeperiod_client.py +++ b/vali_objects/challenge_period/challengeperiod_client.py @@ -202,6 +202,17 @@ def get_plagiarism_miners(self) -> dict: """Get all PLAGIARISM bucket miners as dict {hotkey: start_time}.""" return self._server.get_plagiarism_miners_rpc() + def get_miner_scores(self) -> tuple: + """ + Get cached miner scores for MinerStatisticsManager. + + Returns: + tuple containing: + - asset_softmaxed_scores: dict[asset_class, dict[hotkey, score]] + - asset_competitiveness: dict[asset_class, competitiveness_score] + """ + return self._server.get_miner_scores_rpc() + # ==================== Daemon Methods ==================== def get_daemon_info(self) -> dict: @@ -309,7 +320,7 @@ def inspect( inspection_hotkeys, current_time, hk_to_first_order_time=None, - combined_scores_dict=None + asset_softmaxed_scores=None ): """Run challenge period inspection (exposed for testing).""" return self._server.inspect_rpc( @@ -320,7 +331,7 @@ def inspect( inspection_hotkeys=inspection_hotkeys, current_time=current_time, hk_to_first_order_time=hk_to_first_order_time, - combined_scores_dict=combined_scores_dict + asset_softmaxed_scores=asset_softmaxed_scores ) def to_checkpoint_dict(self) -> dict: diff --git a/vali_objects/challenge_period/challengeperiod_manager.py b/vali_objects/challenge_period/challengeperiod_manager.py index 0e8ac86f8..a803f7689 100644 --- a/vali_objects/challenge_period/challengeperiod_manager.py +++ b/vali_objects/challenge_period/challengeperiod_manager.py @@ -114,6 +114,10 @@ def __init__( self.eliminations_with_reasons: Dict[str, Tuple[str, float]] = {} self.active_miners: Dict[str, Tuple[MinerBucket, int, Optional[MinerBucket], Optional[int]]] = {} + # Cached scores for MinerStatisticsManager + self._cached_asset_softmaxed_scores: Dict[str, Dict[str, float]] = {} + self._cached_asset_competitiveness: Dict[str, float] = {} + # Local lock (NOT shared across processes) - RPC methods are auto-serialized self.eliminations_lock = threading.Lock() @@ -277,7 +281,7 @@ def inspect( inspection_hotkeys: dict[str, int], current_time: int, hk_to_first_order_time: dict[str, int] | None = None, - combined_scores_dict: dict[TradePairCategory, dict] | None = None, + asset_softmaxed_scores: dict[str, dict] | None = None, ) -> tuple[list[str], list[str], dict[str, tuple[str, float]]]: """ Runs a screening process to eliminate miners who didn't pass the challenge period. Does not modify the challenge period in memory. @@ -341,11 +345,12 @@ def inspect( miners_to_eliminate[hotkey] = (EliminationReason.FAILED_CHALLENGE_PERIOD_DRAWDOWN.value, recorded_drawdown_percentage) continue + # Minimum positions check not necessary if they have ledger. If they have a ledger, they can be scored. # Get hotkey to positions dict that only includes the inspection miner - has_minimum_positions, inspection_positions = ChallengePeriodManager.screen_minimum_positions(positions, hotkey) - if not has_minimum_positions: - miners_not_enough_positions.append(hotkey) - continue + # has_minimum_positions, inspection_positions = ChallengePeriodManager.screen_minimum_positions(positions, hotkey) + # if not has_minimum_positions: + # miners_not_enough_positions.append(hotkey) + # continue # Check if miner has selected an asset class (only enforce after selection time) if current_time >= ASSET_CLASS_SELECTION_TIME_MS and not self.asset_selection_client.get_asset_selection(hotkey): @@ -370,25 +375,30 @@ def inspect( all_miner_account_sizes = self._contract_client.get_all_miner_account_sizes(timestamp_ms=current_time) # Use provided scores dict if available (for testing), otherwise compute scores - if combined_scores_dict is None: + if asset_softmaxed_scores is None: # Score all rank-eligible miners (including those without minimum days) for accurate threshold scoring_hotkeys = success_hotkeys + rank_eligible_hotkeys scoring_ledgers = {hotkey: ledger for hotkey, ledger in ledger.items() if hotkey in scoring_hotkeys} scoring_positions = {hotkey: pos_list for hotkey, pos_list in positions.items() if hotkey in scoring_hotkeys} - combined_scores_dict = Scoring.score_miners( - ledger_dict=scoring_ledgers, - positions=scoring_positions, - asset_class_min_days=asset_class_min_days, - evaluation_time_ms=current_time, - weighting=True, - all_miner_account_sizes=all_miner_account_sizes + asset_competitiveness, asset_softmaxed_scores = Scoring.score_miner_asset_classes( + ledger_dict=scoring_ledgers, + positions=scoring_positions, + asset_class_min_days=asset_class_min_days, + evaluation_time_ms=current_time, + weighting=True, + all_miner_account_sizes=all_miner_account_sizes ) + # Cache scores for MinerStatisticsManager + self._cached_asset_softmaxed_scores = asset_softmaxed_scores + self._cached_asset_competitiveness = asset_competitiveness + + hotkeys_to_promote, hotkeys_to_demote = self.evaluate_promotions( success_hotkeys, promotion_eligible_hotkeys, - combined_scores_dict + asset_softmaxed_scores ) bt.logging.info(f"Challenge Period: evaluated {len(promotion_eligible_hotkeys)}/{len(inspection_hotkeys)} miners eligible for promotion") @@ -404,13 +414,8 @@ def evaluate_promotions( self, success_hotkeys, promotion_eligible_hotkeys, - combined_scores_dict + asset_softmaxed_scores ) -> tuple[list[str], list[str]]: - - # score them based on asset class - asset_combined_scores = Scoring.combine_scores(combined_scores_dict) - asset_softmaxed_scores = Scoring.softmax_by_asset(asset_combined_scores) - # Get asset class selections for filtering during threshold calculation miner_asset_selections = {} all_selections = self.asset_selection_client.get_all_miner_selections() @@ -456,6 +461,17 @@ def evaluate_promotions( return list(promote_hotkeys), list(demote_hotkeys) + def get_miner_scores(self) -> tuple[Dict[str, Dict[str, float]], Dict[str, float]]: + """ + Get cached miner scores for MinerStatisticsManager. + + Returns: + tuple containing: + - asset_softmaxed_scores: dict[asset_class, dict[hotkey, score]] + - asset_competitiveness: dict[asset_class, competitiveness_score] + """ + return self._cached_asset_softmaxed_scores, self._cached_asset_competitiveness + @staticmethod def screen_minimum_interaction(ledger_element) -> bool: """Check if miner has minimum number of trading days.""" diff --git a/vali_objects/challenge_period/challengeperiod_server.py b/vali_objects/challenge_period/challengeperiod_server.py index 0e4fd5b6c..95711beb7 100644 --- a/vali_objects/challenge_period/challengeperiod_server.py +++ b/vali_objects/challenge_period/challengeperiod_server.py @@ -191,6 +191,10 @@ def get_plagiarism_miners_rpc(self) -> dict: """Get all PLAGIARISM bucket miners as dict {hotkey: start_time}.""" return self._manager.get_plagiarism_miners() + def get_miner_scores_rpc(self) -> tuple: + """Get cached miner scores for MinerStatisticsManager.""" + return self._manager.get_miner_scores() + # ==================== Elimination Reasons RPC Methods ==================== def get_all_elimination_reasons_rpc(self) -> dict: @@ -342,7 +346,7 @@ def inspect_rpc( inspection_hotkeys: dict, current_time: int, hk_to_first_order_time: dict = None, - combined_scores_dict: dict = None + asset_softmaxed_scores: dict = None ) -> tuple: """Run challenge period inspection (exposed for testing).""" return self._manager.inspect( @@ -353,7 +357,7 @@ def inspect_rpc( inspection_hotkeys, current_time, hk_to_first_order_time, - combined_scores_dict + asset_softmaxed_scores ) def to_checkpoint_dict_rpc(self) -> dict: diff --git a/vali_objects/statistics/miner_statistics_manager.py b/vali_objects/statistics/miner_statistics_manager.py index fd1513aea..c633fa75f 100644 --- a/vali_objects/statistics/miner_statistics_manager.py +++ b/vali_objects/statistics/miner_statistics_manager.py @@ -217,6 +217,7 @@ def __init__( from vali_objects.contract.contract_client import ContractClient from vali_objects.vali_dataclasses.ledger.perf.perf_ledger_client import PerfLedgerClient from vali_objects.plagiarism.plagiarism_detector_client import PlagiarismDetectorClient + from vali_objects.utils.asset_selection.asset_selection_client import AssetSelectionClient self._position_client = PositionManagerClient( port=ValiConfig.RPC_POSITIONMANAGER_PORT, @@ -228,6 +229,7 @@ def __init__( self._perf_ledger_client = PerfLedgerClient(connection_mode=connection_mode) self._plagiarism_detector_client = PlagiarismDetectorClient(connection_mode=connection_mode) self._contract_client = ContractClient(connection_mode=connection_mode) + self._asset_selection_client = AssetSelectionClient(connection_mode=connection_mode) self.metrics_calculator = MetricsCalculator(metrics=metrics) @@ -266,6 +268,11 @@ def plagiarism_detector(self): """Get plagiarism detector client.""" return self._plagiarism_detector_client + @property + def asset_selection_manager(self): + """Get asset selection client.""" + return self._asset_selection_client + # ==================== Ranking / Percentile Helpers ==================== def rank_dictionary(self, d: list[tuple[str, float]], ascending: bool = False) -> dict[str, int]: @@ -603,7 +610,7 @@ def miner_asset_class_scores( self, hotkey: str, asset_softmaxed_scores: dict[str, dict[str, float]], - asset_class_weights: dict[str, float] = None + miner_asset_selections: dict[str, str] ) -> dict[str, dict[str, float]]: """ Extract individual miner's scores and rankings for each asset class. @@ -612,6 +619,7 @@ def miner_asset_class_scores( hotkey: The miner's hotkey asset_softmaxed_scores: A dictionary with softmax scores for each miner within each asset class asset_class_weights: A dictionary with emission weights for each asset class + miner_asset_selections: A dictionary mapping hotkeys to their selected asset class Returns: asset_class_data: dict with asset class as key and score/rank/percentile info as value @@ -620,16 +628,19 @@ def miner_asset_class_scores( for asset_class, miner_scores in asset_softmaxed_scores.items(): if hotkey in miner_scores: - asset_class_percentiles = self.percentile_rank_dictionary(miner_scores.items()) - asset_class_ranks = self.rank_dictionary(miner_scores.items()) + filtered_scores = [ + (hk, score) for hk, score in miner_scores.items() + if miner_asset_selections.get(hk) == asset_class + ] + + asset_class_percentiles = self.percentile_rank_dictionary(filtered_scores) + asset_class_ranks = self.rank_dictionary(filtered_scores) # Score is the only one directly impacted by the asset class weighting, each score element should show the overall scoring contribution miner_score = miner_scores.get(hotkey) - asset_emission = asset_class_weights.get(asset_class, 0.0) - aggregated_score = miner_score * asset_emission asset_class_data[asset_class] = { - "score": aggregated_score, + "score": miner_score, "rank": asset_class_ranks.get(hotkey, 0), "percentile": asset_class_percentiles.get(hotkey, 0.0) * 100 } @@ -647,9 +658,8 @@ def get_printable_config(self) -> Dict[str, Any]: and key not in ['BASE_DIR', 'base_directory'] } - # Add asset class breakdown with subcategory weights + # Add asset class breakdown printable_config['asset_class_breakdown'] = ValiConfig.ASSET_CLASS_BREAKDOWN - printable_config['trade_pairs_by_subcategory'] = TradePair.subcategories() return printable_config @@ -657,7 +667,7 @@ def get_printable_config(self) -> Dict[str, Any]: def generate_miner_statistics_data( self, - time_now: int = None, + time_now: int, checkpoints: bool = True, risk_report: bool = False, selected_miner_hotkeys: List[str] = None, @@ -717,23 +727,11 @@ def generate_miner_statistics_data( ) bt.logging.info(f"generate_minerstats asset_class_min_days: {asset_class_min_days}") all_miner_account_sizes = self.contract_manager.get_all_miner_account_sizes(timestamp_ms=time_now) - success_competitiveness, asset_softmaxed_scores = Scoring.score_miner_asset_classes( - ledger_dict=filtered_ledger, - positions=filtered_positions, - asset_class_min_days=asset_class_min_days, - evaluation_time_ms=time_now, - weighting=final_results_weighting, - all_miner_account_sizes=all_miner_account_sizes - ) # returns asset competitiveness dict, asset softmaxed scores - # Get asset class weights from config - asset_class_weights = { - asset_class: config.get('emission', 0.0) - for asset_class, config in ValiConfig.ASSET_CLASS_BREAKDOWN.items() - } - asset_aggregated_scores = Scoring.asset_class_score_aggregation( - asset_softmaxed_scores - ) + # Get cached scores from ChallengePeriodManager (computed in evaluate_promotions) + asset_softmaxed_scores, success_competitiveness = self.challengeperiod_manager.get_miner_scores() + + miner_asset_selections = self.asset_selection_manager.get_all_miner_selections() # For weighting logic: gather "successful" checkpoint-based results successful_ledger = self._perf_ledger_client.filtered_ledger_for_scoring(hotkeys=challengeperiod_success_hotkeys) @@ -856,14 +854,14 @@ def generate_miner_statistics_data( # Build a small function to extract ScoreResult -> dict for each metric def build_scores_dict(metric_set: Dict[str, Dict[str, ScoreResult]]) -> Dict[str, Dict[str, float]]: out = {} - for subcategory, metric_scores in metric_set.items(): - out[subcategory] = {} + for asset_class, metric_scores in metric_set.items(): + out[asset_class] = {} for metric_name, hotkey_map in metric_scores.items(): sr = hotkey_map.get(hotkey) if sr is not None: - out[subcategory][metric_name] = sr.to_dict() + out[asset_class][metric_name] = sr.to_dict() else: - out[subcategory][metric_name] = {} + out[asset_class][metric_name] = {} return out base_dict = build_scores_dict(base_scores) @@ -919,7 +917,7 @@ def build_scores_dict(metric_set: Dict[str, Dict[str, ScoreResult]]) -> Dict[str asset_class_performance = self.miner_asset_class_scores( hotkey, asset_softmaxed_scores, - asset_class_weights + miner_asset_selections ) final_miner_dict = { diff --git a/vali_objects/utils/limit_order/limit_order_client.py b/vali_objects/utils/limit_order/limit_order_client.py index 1a01a5d9a..007bb4763 100644 --- a/vali_objects/utils/limit_order/limit_order_client.py +++ b/vali_objects/utils/limit_order/limit_order_client.py @@ -283,18 +283,22 @@ def get_position_for(self, hotkey: str, order): """ return self._server.get_position_for_rpc(hotkey, order) - def create_sltp_orders(self, miner_hotkey: str, parent_order): + def create_sltp_order(self, miner_hotkey: str, parent_order): """ - Create SL/TP bracket orders for testing via RPC. + Create SL/TP bracket order from a filled market order via RPC. + + Called by OrderProcessor after a market order with stop_loss/take_profit + fields is successfully filled. Creates a bracket order that will trigger + when price hits SL or TP levels. Args: miner_hotkey: Miner's hotkey - parent_order: Parent order object + parent_order: Parent order object - the filled market order with SL/TP fields Returns: None """ - return self._server.create_sltp_orders_rpc(miner_hotkey, parent_order) + return self._server.create_sltp_order_rpc(miner_hotkey, parent_order) def evaluate_bracket_trigger_price(self, order, position, price_source): """ diff --git a/vali_objects/utils/limit_order/limit_order_manager.py b/vali_objects/utils/limit_order/limit_order_manager.py index be61840fa..05b8eba68 100644 --- a/vali_objects/utils/limit_order/limit_order_manager.py +++ b/vali_objects/utils/limit_order/limit_order_manager.py @@ -73,9 +73,9 @@ def __init__(self, running_unit_tests=False, serve=True, connection_mode: RPCCon # Regular Python dict - NO IPC! self._limit_orders = {} self._last_fill_time = {} + self._last_print_time_ms = 0 self._read_limit_orders_from_disk() - self._reset_counters() # Create dedicated locks for protecting self._limit_orders dictionary # Convert limit orders structure to format expected by PositionLocks @@ -467,7 +467,10 @@ def check_and_fill_limit_orders(self, call_id=None): if self.running_unit_tests: print(f"[CHECK_AND_FILL_CALLED] check_and_fill_limit_orders(call_id={call_id}) called, {len(self._limit_orders)} trade pairs") - bt.logging.info(f"Checking limit orders across {len(self._limit_orders)} trade pairs") + if now_ms - self._last_print_time_ms > 60 * 1000: + total_orders = sum(len(orders) for hotkey_dict in self._limit_orders.values() for orders in hotkey_dict.values()) + bt.logging.info(f"Checking {total_orders} limit orders across {len(self._limit_orders)} trade pairs") + self._last_print_time_ms = now_ms for trade_pair, hotkey_dict in self._limit_orders.items(): # Check if market is open @@ -520,7 +523,8 @@ def check_and_fill_limit_orders(self, call_id=None): # This prevents rapid sequential fills and enforces rate limiting. break - bt.logging.info(f"Limit order check complete: checked={total_checked}, filled={total_filled}") + if total_filled > 0: + bt.logging.info(f"Limit order check complete: checked={total_checked}, filled={total_filled}") return { 'checked': total_checked, @@ -717,7 +721,11 @@ def _fill_limit_order_with_price_source(self, miner_hotkey, order, price_source, self._last_fill_time[trade_pair][miner_hotkey] = fill_time if order.execution_type == ExecutionType.LIMIT and (order.stop_loss is not None or order.take_profit is not None): - self._create_sltp_order(miner_hotkey, order) + self.create_sltp_order(miner_hotkey, order) + + except SignalException as e: + error_msg = f"Limit order [{order.order_uuid}] filled successfully, but bracket order creation failed: {e}" + bt.logging.warning(error_msg) except Exception as e: error_msg = f"Could not fill limit order [{order.order_uuid}]: {e}. Cancelling order" @@ -766,7 +774,7 @@ def _close_limit_order(self, miner_hotkey, order, src, time_ms): bt.logging.info(f"Successfully closed limit order [{order_uuid}] [{trade_pair_id}] for [{miner_hotkey}]") - def _create_sltp_order(self, miner_hotkey, parent_order): + def create_sltp_order(self, miner_hotkey, parent_order): """ Create a single bracket order with both stop loss and take profit. Replaces the previous two-order SLTP system. @@ -780,59 +788,52 @@ def _create_sltp_order(self, miner_hotkey, parent_order): # Require at least one of SL or TP to be set if parent_order.stop_loss is None and parent_order.take_profit is None: - bt.logging.debug(f"No SL/TP specified for order [{parent_order.order_uuid}], skipping bracket creation") - return + raise SignalException(f"No SL/TP specified for order [{parent_order.order_uuid}]") # Validate SL/TP against fill price before creating bracket order fill_price = parent_order.price order_type = parent_order.order_type + if not fill_price: + raise SignalException(f"Unexpected: no fill price from order [{parent_order.order_uuid}]") + # Validate stop loss and take profit based on order type if order_type == OrderType.LONG: # For LONG positions: # - Stop loss must be BELOW fill price (selling at a loss) # - Take profit must be ABOVE fill price (selling at a gain) if parent_order.stop_loss is not None and parent_order.stop_loss >= fill_price: - bt.logging.warning( + raise SignalException( f"Invalid LONG bracket order [{parent_order.order_uuid}]: " - f"stop_loss ({parent_order.stop_loss}) must be < fill_price ({fill_price}). " - f"Skipping bracket creation" + f"stop_loss ({parent_order.stop_loss}) must be < fill_price ({fill_price})" ) - return if parent_order.take_profit is not None and parent_order.take_profit <= fill_price: - bt.logging.warning( + raise SignalException( f"Invalid LONG bracket order [{parent_order.order_uuid}]: " - f"take_profit ({parent_order.take_profit}) must be > fill_price ({fill_price}). " - f"Skipping bracket creation" + f"take_profit ({parent_order.take_profit}) must be > fill_price ({fill_price})" ) - return elif order_type == OrderType.SHORT: # For SHORT positions: # - Stop loss must be ABOVE fill price (buying back at a loss) # - Take profit must be BELOW fill price (buying back at a gain) if parent_order.stop_loss is not None and parent_order.stop_loss <= fill_price: - bt.logging.warning( + raise SignalException( f"Invalid SHORT bracket order [{parent_order.order_uuid}]: " - f"stop_loss ({parent_order.stop_loss}) must be > fill_price ({fill_price}). " - f"Skipping bracket creation" + f"stop_loss ({parent_order.stop_loss}) must be > fill_price ({fill_price})" ) - return if parent_order.take_profit is not None and parent_order.take_profit >= fill_price: - bt.logging.warning( + raise SignalException( f"Invalid SHORT bracket order [{parent_order.order_uuid}]: " - f"take_profit ({parent_order.take_profit}) must be < fill_price ({fill_price}). " - f"Skipping bracket creation" + f"take_profit ({parent_order.take_profit}) must be < fill_price ({fill_price})" ) - return else: - bt.logging.error( + raise SignalException( f"Invalid order type for bracket order [{parent_order.order_uuid}]: {order_type}. " f"Must be LONG or SHORT" ) - return try: # Create single bracket order with both SL and TP @@ -872,6 +873,7 @@ def _create_sltp_order(self, miner_hotkey, parent_order): except Exception as e: bt.logging.error(f"Error creating bracket order: {e}") bt.logging.error(traceback.format_exc()) + raise SignalException(f"Error creating bracket order: {e}") def _get_position_for(self, hotkey, order): """Get open position for hotkey and trade pair.""" @@ -893,14 +895,9 @@ def _evaluate_limit_trigger_price(self, order_type, position, ps, limit_price): bid_price = ps.bid if ps.bid > 0 else ps.open ask_price = ps.ask if ps.ask > 0 else ps.open - position_type = position.position_type if position else None - - buy_type = order_type == OrderType.LONG or (order_type == OrderType.FLAT and position_type == OrderType.SHORT) - sell_type = order_type == OrderType.SHORT or (order_type == OrderType.FLAT and position_type == OrderType.LONG) - - if buy_type: + if order_type == OrderType.LONG: return limit_price if ask_price <= limit_price else None - elif sell_type: + elif order_type == OrderType.SHORT: return limit_price if bid_price >= limit_price else None else: return None @@ -1027,11 +1024,6 @@ def _delete_from_disk(self, miner_hotkey, order): except Exception as e: bt.logging.error(f"Error deleting limit order from disk: {e}") - def _reset_counters(self): - """Reset evaluation counters.""" - self._limit_orders_evaluated = 0 - self._limit_orders_filled = 0 - def sync_limit_orders(self, sync_data): """Sync limit orders from external source.""" if not sync_data: diff --git a/vali_objects/utils/limit_order/limit_order_server.py b/vali_objects/utils/limit_order/limit_order_server.py index 7923ba3dd..329ea64a3 100644 --- a/vali_objects/utils/limit_order/limit_order_server.py +++ b/vali_objects/utils/limit_order/limit_order_server.py @@ -364,21 +364,17 @@ def get_position_for_rpc(self, hotkey, order): return self._manager._get_position_for(hotkey, order) - def create_sltp_orders_rpc(self, miner_hotkey, parent_order): + def create_sltp_order_rpc(self, miner_hotkey, parent_order): """ - RPC method to create SL/TP bracket orders for testing. - + RPC method to create SL/TP bracket order from a filled order. Args: miner_hotkey: Miner's hotkey - parent_order: Parent order object (auto-pickled) + parent_order: Parent order object (auto-pickled) - the filled order Returns: None """ - if not self.running_unit_tests: - raise Exception('create_sltp_orders_rpc can only be called in unit test mode') - - return self._manager._create_sltp_order(miner_hotkey, parent_order) + return self._manager.create_sltp_order(miner_hotkey, parent_order) def evaluate_bracket_trigger_price_rpc(self, order, position, price_source): """ diff --git a/vali_objects/utils/limit_order/order_processor.py b/vali_objects/utils/limit_order/order_processor.py index 6c16600f4..1799b7384 100644 --- a/vali_objects/utils/limit_order/order_processor.py +++ b/vali_objects/utils/limit_order/order_processor.py @@ -440,6 +440,17 @@ def process_order( if err_msg: raise SignalException(err_msg) + # Create bracket order for SL/TP if market order succeeded and position is open + # The created_order already contains stop_loss/take_profit from the signal + if created_order and (created_order.stop_loss or created_order.take_profit): + if updated_position and not updated_position.is_closed_position: + try: + limit_order_client.create_sltp_order(miner_hotkey, created_order) + except SignalException as e: + raise SignalException( + f"Market order filled successfully, but bracket order creation failed: {e}" + ) + return OrderProcessingResult( execution_type=ExecutionType.MARKET, order=created_order,