diff --git a/internal/migrations/038-order-book-queries.sql b/internal/migrations/038-order-book-queries.sql index f15dbe15..f666f677 100644 --- a/internal/migrations/038-order-book-queries.sql +++ b/internal/migrations/038-order-book-queries.sql @@ -5,6 +5,7 @@ * - get_order_book($query_id, $outcome) - View market orders * - get_user_positions() - View caller's portfolio * - get_market_depth($query_id, $outcome) - Aggregated volume (optional) + * - get_full_market_depth($query_id) - Both outcomes' volume in one read * - get_best_prices($query_id, $outcome) - Market spread (optional) * - get_user_collateral() - User's locked value (optional) * @@ -211,6 +212,75 @@ CREATE OR REPLACE ACTION get_market_depth( } }; +/** + * get_full_market_depth($query_id) + * + * Same aggregation as get_market_depth, but for BOTH outcomes of a market in a + * single read, tagged with the outcome each price level belongs to. + * + * Why this exists beside get_market_depth: a market's YES and NO shares are two + * views of one position, so a resting NO sell at 93 is a standing bid for YES at + * 7 — a trader hits it by selling YES, and the pair burns. Anything that folds + * the two books into one ladder therefore needs both sides. Reading them as two + * separate calls reads them at two independent points in time, and an order that + * lands between the two can appear on one side and not the other. The stitched + * ladder can then show a bid above an ask that never existed at any single + * moment. One statement over one snapshot removes that whole class of artifact. + * + * get_market_depth stays as it is. Callers that want one outcome — the + * market-maker bot, the LP bot, depth charts — should keep using it. + * + * Parameters: + * - $query_id: Market ID + * + * Returns TABLE of: + * - outcome: TRUE for YES, FALSE for NO + * - price: Absolute price level (1-99) + * - buy_volume: Total shares in buy orders at this outcome and price + * - sell_volume: Total shares in sell orders at this outcome and price + * + * Sorting: YES levels first, then NO levels; price ascending within each. + * + * Usage: + * kwil-cli call-action get_full_market_depth int:1 + * + * Example Output: + * outcome | price | buy_volume | sell_volume + * TRUE | 55 | 150 | 0 + * FALSE | 30 | 200 | 0 + * FALSE | 40 | 0 | 100 + */ +CREATE OR REPLACE ACTION get_full_market_depth( + $query_id INT +) PUBLIC VIEW RETURNS TABLE( + outcome BOOL, + price INT, + buy_volume INT8, + sell_volume INT8 +) { + if $query_id IS NULL { + ERROR('query_id is required'); + } + + -- Aggregate volume at each (outcome, price level) pair + for $depth in + SELECT + outcome, + abs(price) as abs_price, + COALESCE(SUM(CASE WHEN price < 0 THEN amount ELSE 0::INT8 END)::INT8, 0::INT8) as buy_vol, + COALESCE(SUM(CASE WHEN price > 0 THEN amount ELSE 0::INT8 END)::INT8, 0::INT8) as sell_vol + FROM ob_positions + WHERE query_id = $query_id + AND price != 0 -- Exclude holdings + GROUP BY outcome, abs(price) + ORDER BY + outcome DESC, -- TRUE (YES) before FALSE (NO) + abs(price) ASC -- Best prices first within an outcome + { + RETURN NEXT $depth.outcome, $depth.abs_price, $depth.buy_vol, $depth.sell_vol; + } +}; + /** * get_best_prices($query_id, $outcome) * diff --git a/tests/streams/order_book/queries_test.go b/tests/streams/order_book/queries_test.go index 8c7a1de6..2763f1f6 100644 --- a/tests/streams/order_book/queries_test.go +++ b/tests/streams/order_book/queries_test.go @@ -63,6 +63,12 @@ func TestQueries(t *testing.T) { testGetMarketDepthEmpty(t), testGetMarketDepthAggregation(t), + // get_full_market_depth tests + testGetFullMarketDepthEmpty(t), + testGetFullMarketDepthBothOutcomes(t), + testGetFullMarketDepthMatchesPerOutcomeReads(t), + testGetFullMarketDepthExcludesHoldings(t), + // get_best_prices tests testGetBestPricesNoOrders(t), testGetBestPricesOnlyBuy(t), @@ -563,6 +569,159 @@ func testGetMarketDepthAggregation(t *testing.T) func(context.Context, *kwilTest } } +// ============================================================================ +// get_full_market_depth Tests +// ============================================================================ + +func testGetFullMarketDepthEmpty(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil // Reset for this test + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xA1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1") + + err = giveBalanceQueries(ctx, platform, user.Address(), "100000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &user) + + require.Empty(t, readFullMarketDepth(t, ctx, platform, &user, queryID)) + + return nil + } +} + +func testGetFullMarketDepthBothOutcomes(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil // Reset for this test + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user1 := util.Unsafe_NewEthereumAddressFromString("0xB2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2") + user2 := util.Unsafe_NewEthereumAddressFromString("0xC3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3") + + err = giveBalanceQueries(ctx, platform, user1.Address(), "1000000000000000000000") + require.NoError(t, err) + err = giveBalanceQueries(ctx, platform, user2.Address(), "1000000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &user1) + + // Two YES buys at the same price, to prove aggregation still happens + // per outcome and not across the market. + err = callPlaceBuyOrderQueries(ctx, platform, &user1, queryID, true, 55, 100, nil) + require.NoError(t, err) + err = callPlaceBuyOrderQueries(ctx, platform, &user2, queryID, true, 55, 50, nil) + require.NoError(t, err) + + // A NO buy at 30. It cannot mint against the YES buys at 55 (55 + 30 + // is not 100), so it rests. + err = callPlaceBuyOrderQueries(ctx, platform, &user1, queryID, false, 30, 200, nil) + require.NoError(t, err) + + // A split at 60 leaves YES holdings and a NO sell at 40. The NO sell + // does not cross the NO buy at 30, and has no YES sell at 60 to burn + // against, so it rests too. + err = callPlaceSplitOrderQueries(ctx, platform, &user2, queryID, 60, 100, nil) + require.NoError(t, err) + + // YES levels first, then NO, each ascending by price. The NO sell at 40 + // is the interesting row: consolidated into the YES frame it is a bid + // for YES at 60, which a single-outcome read can never show. + require.Equal(t, []FullMarketDepth{ + {Outcome: true, Price: 55, BuyVolume: 150, SellVolume: 0}, + {Outcome: false, Price: 30, BuyVolume: 200, SellVolume: 0}, + {Outcome: false, Price: 40, BuyVolume: 0, SellVolume: 100}, + }, readFullMarketDepth(t, ctx, platform, &user1, queryID)) + + return nil + } +} + +// The whole point of this action is that it answers what two get_market_depth +// calls answer, without the gap between them. If the two ever disagree on the +// numbers, callers that switched to the single read silently get a different +// book, so pin them together. +func testGetFullMarketDepthMatchesPerOutcomeReads(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil // Reset for this test + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user1 := util.Unsafe_NewEthereumAddressFromString("0xD4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4") + user2 := util.Unsafe_NewEthereumAddressFromString("0xE5E5E5E5E5E5E5E5E5E5E5E5E5E5E5E5E5E5E5E5") + + err = giveBalanceQueries(ctx, platform, user1.Address(), "1000000000000000000000") + require.NoError(t, err) + err = giveBalanceQueries(ctx, platform, user2.Address(), "1000000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &user1) + + err = callPlaceBuyOrderQueries(ctx, platform, &user1, queryID, true, 45, 100, nil) + require.NoError(t, err) + err = callPlaceSplitOrderQueries(ctx, platform, &user2, queryID, 70, 60, nil) + require.NoError(t, err) + err = callPlaceBuyOrderQueries(ctx, platform, &user2, queryID, false, 25, 80, nil) + require.NoError(t, err) + + var want []FullMarketDepth + for _, outcome := range []bool{true, false} { + err = callGetMarketDepth(ctx, platform, &user1, queryID, outcome, func(row *common.Row) error { + want = append(want, FullMarketDepth{ + Outcome: outcome, + Price: int(row.Values[0].(int64)), + BuyVolume: row.Values[1].(int64), + SellVolume: row.Values[2].(int64), + }) + return nil + }) + require.NoError(t, err) + } + require.NotEmpty(t, want, "the fixture must quote both outcomes for this to mean anything") + + require.Equal(t, want, readFullMarketDepth(t, ctx, platform, &user1, queryID)) + + return nil + } +} + +func testGetFullMarketDepthExcludesHoldings(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + lastBalancePointQueries = nil // Reset for this test + lastTrufBalancePointQueries = nil + + err := erc20bridge.ForTestingInitializeExtension(ctx, platform) + require.NoError(t, err) + + user := util.Unsafe_NewEthereumAddressFromString("0xF6F6F6F6F6F6F6F6F6F6F6F6F6F6F6F6F6F6F6F6") + + err = giveBalanceQueries(ctx, platform, user.Address(), "500000000000000000000") + require.NoError(t, err) + + queryID, _ := createTestMarketQueries(t, ctx, platform, &user) + + // Leaves 100 YES holdings at price 0 and a NO sell at 40. + err = callPlaceSplitOrderQueries(ctx, platform, &user, queryID, 60, 100, nil) + require.NoError(t, err) + + // Holdings are not orders. Nobody can trade against them, so no YES row + // may appear at all. + require.Equal(t, []FullMarketDepth{ + {Outcome: false, Price: 40, BuyVolume: 0, SellVolume: 100}, + }, readFullMarketDepth(t, ctx, platform, &user, queryID)) + + return nil + } +} + // ============================================================================ // get_best_prices Tests // ============================================================================ @@ -950,6 +1109,26 @@ func callGetMarketDepth(ctx context.Context, platform *kwilTesting.Platform, sig []any{queryID, outcome}, resultFn) } +func callGetFullMarketDepth(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, queryID int, resultFn func(*common.Row) error) error { + return callActionQueries(ctx, platform, signer, "get_full_market_depth", + []any{queryID}, resultFn) +} + +func readFullMarketDepth(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, queryID int) []FullMarketDepth { + var levels []FullMarketDepth + err := callGetFullMarketDepth(ctx, platform, signer, queryID, func(row *common.Row) error { + levels = append(levels, FullMarketDepth{ + Outcome: row.Values[0].(bool), + Price: int(row.Values[1].(int64)), + BuyVolume: row.Values[2].(int64), + SellVolume: row.Values[3].(int64), + }) + return nil + }) + require.NoError(t, err) + return levels +} + func callGetBestPrices(ctx context.Context, platform *kwilTesting.Platform, signer *util.EthereumAddress, queryID int, outcome bool, resultFn func(*common.Row) error) error { return callActionQueries(ctx, platform, signer, "get_best_prices", []any{queryID, outcome}, resultFn) @@ -1031,3 +1210,10 @@ type MarketDepth struct { BuyVolume int64 SellVolume int64 } + +type FullMarketDepth struct { + Outcome bool + Price int + BuyVolume int64 + SellVolume int64 +}