Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2606,6 +2606,72 @@ This means blockchain fills are recorded in both OnChainTradeEvent::Filled
(audit trail) and PositionEvent::OnChainOrderFilled (position tracking), but
they serve different purposes in different bounded contexts.

Raindex settles equity in ERC-4626 wrapped shares while the broker, Position,
and P&L domains use underlying shares. `OnChainTradeEvent::Filled` therefore
retains the wrapped quantity and wrapped-share price as the immutable chain
Comment thread
rain-marvin[bot] marked this conversation as resolved.
facts. Accounting witnesses that raw fill first. Only a fill that is not yet
acknowledged, and whose Position fill or trading-disabled exclusion record is
not already durable, then reads the wrapper's underlying-per-wrapped ratio on
the fill's chain at the fill's confirmed block, so the accounting RPC must serve
historical (archive) state. A retry after a crash in either window resumes
without that read. That read returns end-of-block state, so accounting also
reads the ratio at the previous block and logs a warning when the two differ:
the fill is then normalized with the end-of-block ratio, and an operator checks
it. `PositionEvent::OnChainOrderFilled` stores the resulting
underlying-equivalent quantity and price, plus the exact wrapped quantity, while
the same atomic Position acknowledgement stores the proven ratio on its
fill-application event. The quantity is the wrapped quantity times the ratio,
floored to 18 decimals, and the price is the wrapped-share price divided by the
exact ratio, so quantity times price equals the original cash notional up to
that floor. The price never absorbs the floor: a few-wei fill must not write a
distorted last price. Onchain inventory vault slots hold wrapped shares, so the
inventory reactor applies the wrapped quantity, not the underlying one. The
cover instruction for a fill on a trading-disabled asset and the `process-tx`
report also use the underlying-equivalent quantity and price, because the
operator covers at the broker. A missing block, unavailable historical state, or
invalid ratio fails accounting closed before a Position write or broker hedge.
It never falls back to the latest ratio or assumes 1:1. A fill whose wrapped
quantity converts to zero underlying shares, for example a dust fill at a ratio
below 1 or any fill on a wrapper whose ratio at the fill block is zero, can
never succeed on retry, so it is recorded in `skipped_fills` as
`zero_underlying_amount`, acknowledged, and logged for manual reconciliation
instead of stopping the fill worker. The record is written before the
acknowledgement, and a failed write leaves the fill pending for the retry. The
fill worker and `process-tx` share this skip, and an existing record finishes it
on a resume or a rerun with no ratio read.

Position events written before ratio evidence was persisted remain replayable,
and P&L counts their fills 1:1 in wrapped shares, as before. That is exact for a
wrapper whose ratio has always been 1. For a wrapper whose ratio is above 1,
such as SGOV, P&L that includes those fills is approximate, off by about the
ratio minus 1. The report warns when the queried range includes such fills for a
symbol, unless every ratio recorded for that symbol is exactly 1. Fills after
this change carry their proven ratio and are exact.

Position replay has the same legacy offset. The old bot booked wrapped fills 1:1
and hedged them 1:1 with broker shares, so on a wrapper whose ratio is not 1 the
real exposure differs from the Position net, even when that net is zero. The
real exposure is the vault's wrapped holdings times the current ratio against
the broker position, or the legacy fills recomputed at each fill's own ratio. It
is bounded and does not grow. An operator can correct it after deploy while the
market is closed: record a manual adjustment to a net of zero, then a second
manual adjustment to the real exposure in underlying shares, with a price, so
the bot hedges it at the next open. A manual adjustment only rewrites the net
and places no broker order, and the Position rejects one while a hedge is
pending.

Exclusion records that the old bot wrote for fills on a trading-disabled asset
state their cover in wrapped shares, and this change repeats a recorded detail
word for word when it resumes or alerts that fill again. Operators cover the
outstanding `TradingDisabled` rows for wrapped symbols whose ratio is not 1 at
the wrapped quantity times the ratio at its fill block, not at the recorded
figure.

The dashboard trade feed shows onchain fills in wrapped shares, because the
`OnChainTrade` record keeps the raw chain facts, and labels them as wrapped.
Broker hedges are in underlying shares, so at a ratio other than 1 an onchain
fill and its hedge show different quantities for the same exposure.

### Aggregate Design

#### EventSourced Trait
Expand Down Expand Up @@ -2755,8 +2821,10 @@ enum OnChainTradeCommand {
enum OnChainTradeEvent {
Filled {
symbol: Symbol,
// Immutable chain facts, in ERC-4626 wrapped shares.
amount: Decimal,
direction: Direction,
// USDC per wrapped share.
price_usdc: Decimal,
block_number: u64,
block_timestamp: DateTime<Utc>,
Expand Down Expand Up @@ -2822,6 +2890,21 @@ struct TradeId {
}

enum PositionCommand {
// Production fill command: the fill is already normalized into
// underlying shares at the ratio read at its confirmed block.
AcknowledgeNormalizedOnChainFill {
symbol: Symbol,
threshold: ExecutionThreshold,
trade_id: TradeId,
amount: FractionalShares,
wrapped_amount: FractionalShares,
direction: Direction,
price_usdc: Decimal,
block_timestamp: DateTime<Utc>,
block_number: Option<u64>,
underlying_per_wrapped: U256,
},
// Legacy shape with no ratio evidence; tests and fixtures only.
AcknowledgeOnChainFill {
trade_id: TradeId,
amount: FractionalShares,
Expand Down Expand Up @@ -2881,12 +2964,25 @@ enum PositionCommand {
enum PositionEvent {
OnChainOrderFilled {
trade_id: TradeId,
// Underlying (broker) shares after wrapper-ratio normalization.
amount: FractionalShares,
// Exact wrapped shares moved onchain; `None` on legacy events, which
// were recorded 1:1. Inventory applies this, not `amount`.
wrapped_amount: Option<FractionalShares>,
direction: Direction,
// USDC per underlying share; amount * price_usdc keeps the notional.
price_usdc: Decimal,
block_timestamp: DateTime<Utc>,
seen_at: DateTime<Utc>,
},
// Emitted atomically with the fill it applies.
OnChainFillApplied {
trade_id: TradeId,
// Underlying-per-wrapped ratio (18 decimals) read at the fill block;
// `None` on legacy events, which P&L counts 1:1.
underlying_per_wrapped: Option<U256>,
applied_at: DateTime<Utc>,
},
EquityTransferReserved {
reservation_id: EquityTransferReservationId,
reserved_at: DateTime<Utc>,
Expand Down Expand Up @@ -2945,8 +3041,12 @@ enum TriggerReason {

**Business Rules** (enforced in `handle()`):

- `AcknowledgeOnChainFill` serves as the genesis event (initializes the
aggregate on first fill)
- `AcknowledgeNormalizedOnChainFill` serves as the genesis event (initializes
the aggregate on first fill). It carries the fill already normalized into
underlying shares plus the exact wrapped quantity and the proven
underlying-per-wrapped ratio, and records the ratio on `OnChainFillApplied` in
the same transaction. The legacy `AcknowledgeOnChainFill`, which records no
ratio, exists only for tests and fixtures.
- `ManuallyAdjustPosition` is an audited operator repair command that sets the
absolute net exposure after manual trading or rebalancing outside the bot. It
may initialize a missing position, including a zero target, using the
Expand Down
24 changes: 24 additions & 0 deletions crates/cli/src/cli/trading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,16 @@ fn render_process_tx_outcome<W: Write>(
"The fill was excluded from hedging while trading was disabled and is recorded in skipped_fills. The pipeline will not hedge it: {detail}"
)?;
}
ProcessTxOutcome::SkippedZeroUnderlying {
symbol,
chain,
detail,
} => {
writeln!(
stdout,
"The fill on {chain} converts to zero {symbol} shares at its fill-block wrapper ratio, so it was recorded in skipped_fills and is not hedged. Reconcile it by hand: {detail}"
)?;
}
ProcessTxOutcome::PlacementRejected { symbol } => {
writeln!(
stdout,
Expand Down Expand Up @@ -2959,6 +2969,20 @@ mod tests {
"{fill_summary}The fill was excluded from hedging while trading was disabled and is recorded in skipped_fills. The pipeline will not hedge it: cover by SELL\n"
),
),
(
ProcessTxReport {
fill: None,
outcome: ProcessTxOutcome::SkippedZeroUnderlying {
symbol: symbol(),
chain: st0x_evm::Chain::Base,
detail: "converts to zero underlying shares".to_string(),
},
},
format!(
"The fill on base converts to zero {} shares at its fill-block wrapper ratio, so it was recorded in skipped_fills and is not hedged. Reconcile it by hand: converts to zero underlying shares\n",
symbol()
),
),
(
ProcessTxReport {
fill: Some(fill()),
Expand Down
18 changes: 12 additions & 6 deletions dashboard/src/lib/components/trade-history-panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
TRADING_VENUES,
fallbackTradeProtocol,
isOnchainVenue,
shareUnitLabel,
legacyCompatibleVenues,
mergeTradeHistory,
normalizeTrade,
Expand Down Expand Up @@ -336,11 +337,14 @@

const fmtSize = (value: string): string => formatDecimal(value, 3)

// The position price is per underlying share, so it cannot value wrapped
// shares: onchain rows name the unit and leave out the USD value.
const shareTooltip = (trade: TradeEntry): string =>
`${trade.outcome.status !== 'filled' ? 'Order quantity. ' : ''}${equityUsdTooltip(
trade.shares,
positionPrices.get(trade.symbol) ?? null
)}`
`${trade.outcome.status !== 'filled' ? 'Order quantity. ' : ''}${
isOnchainVenue(trade.venue)
? 'Wrapped shares; the broker hedge is in underlying shares.'
: equityUsdTooltip(trade.shares, positionPrices.get(trade.symbol) ?? null)
}`

const isNumeric = (value: unknown): boolean =>
typeof value === 'string' && value !== '' && !Number.isNaN(Number(value))
Expand Down Expand Up @@ -598,7 +602,9 @@
<HoverTooltip tooltip={shareTooltip(trade)}>
<span
class="cursor-help hover:underline hover:decoration-dotted hover:decoration-muted-foreground hover:underline-offset-4"
>{fmtSize(trade.shares)}</span
>{fmtSize(trade.shares)}{#if isOnchainVenue(trade.venue)}<span
class="ml-1 text-muted-foreground">wrapped</span
>{/if}</span
>
</HoverTooltip>
</Table.Cell>
Expand Down Expand Up @@ -645,7 +651,7 @@
<HoverTooltip tooltip={shareTooltip(trade)}>
<span
class="cursor-help font-mono text-xs font-normal text-muted-foreground hover:underline hover:decoration-dotted hover:decoration-muted-foreground hover:underline-offset-4"
>{fmtSize(trade.shares)} shares</span
>{fmtSize(trade.shares)} {shareUnitLabel(trade.venue)}</span
>
</HoverTooltip>
<span class="text-xs font-normal {venueColor(trade.venue)}">{venueLabel(trade.venue)}</span>
Expand Down
36 changes: 36 additions & 0 deletions dashboard/src/lib/components/trade-history-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,42 @@ describe('TradeHistoryPanel', () => {
target.remove()
})

it('marks onchain fill quantities as wrapped next to broker hedges', async () => {
const raindexFill: Trade = {
id: '0x28a364eaa0fc6d505ddd0702cd9949853193bb28c92cdfef68bcd5a803f1806b:7',
occurredAt: '2026-07-31T12:00:00Z',
venue: 'raindex',
direction: 'sell',
symbol: 'SGOV',
shares: '2',
outcome: { status: 'filled' }
}
const hedge: Trade = {
id: 'counter-trade-sgov',
occurredAt: '2026-07-31T12:00:05Z',
venue: 'alpaca',
direction: 'buy',
symbol: 'SGOV',
shares: '2.02',
outcome: { status: 'filled' }
}
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(tradeResponse([hedge, raindexFill], 2)))
)

const { target } = mountPanel()

await vi.waitFor(() => expect(target.textContent).toContain('2 of 2'))
const rows = [...target.querySelectorAll('tbody tr')]
const raindexRow = rows.find((row) => row.textContent.includes('Raindex'))
const alpacaRow = rows.find((row) => row.textContent.includes('Alpaca'))
expect(raindexRow?.textContent).toContain('wrapped')
expect(alpacaRow?.textContent).not.toContain('wrapped')

target.remove()
})

it('renders Bebop fills and removes Bebop from the v3 venue filter', async () => {
const bebopTrade: Trade = {
id: '0x28a364eaa0fc6d505ddd0702cd9949853193bb28c92cdfef68bcd5a803f1806b:194',
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/lib/trade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
legacyCompatibleVenues,
mergeTradeHistory,
normalizeTrade,
shareUnitLabel,
tradeFailureReason,
tradeOutcomeShares,
tradeOutcomeClass,
Expand Down Expand Up @@ -246,6 +247,15 @@ describe('live trade history', () => {
expect(isOnchainVenue('dry_run')).toBe(false)
})

it('labels onchain quantities as wrapped shares and broker quantities as shares', () => {
expect(shareUnitLabel('raindex')).toBe('wrapped shares')
expect(shareUnitLabel('bebop')).toBe('wrapped shares')
expect(shareUnitLabel('uniswap_v4')).toBe('wrapped shares')
expect(shareUnitLabel('unknown_onchain')).toBe('wrapped shares')
expect(shareUnitLabel('alpaca')).toBe('shares')
expect(shareUnitLabel('dry_run')).toBe('shares')
})

it('collapses adapter venue filters for pre-v3 backends', () => {
expect(legacyCompatibleVenues(new Set(['bebop', 'uniswap_v4', 'alpaca']))).toEqual(
new Set(['raindex', 'alpaca'])
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/lib/trade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ export const isTradingVenue = (value: unknown): value is TradingVenue =>

export const isOnchainVenue = (venue: TradingVenue): boolean => VENUE_IS_ONCHAIN[venue]

// Onchain fills settle in ERC-4626 wrapped shares, while broker hedges are in
// underlying shares. At a wrapper ratio other than 1 the two quantities differ
// for the same exposure, so onchain quantities must say which unit they use.
export const shareUnitLabel = (venue: TradingVenue): string =>
isOnchainVenue(venue) ? 'wrapped shares' : 'shares'

export type TradeProtocol = 'terminal_outcomes_v3' | 'terminal_outcomes_v2' | 'terminal_outcomes_v1'

const TRADE_PROTOCOL_FALLBACK = {
Expand Down
14 changes: 14 additions & 0 deletions migrations/20260925161411_pnl_wrapped_fill_basis.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- NULL marks a legacy Position fill whose wrapped-to-underlying basis was not
-- persisted. New rows carry the exact fixed-18 ERC-4626 ratio read at the
-- fill's confirmed block.
ALTER TABLE pnl_onchain_fill
ADD COLUMN underlying_per_wrapped_fixed18 TEXT;

-- The evidence arrives on the immediately following Position event. Retaining
-- its rowid keeps historical `as_of` reads from observing evidence that had not
-- been recorded yet.
ALTER TABLE pnl_onchain_fill
ADD COLUMN underlying_per_wrapped_event_rowid INTEGER;

CREATE INDEX pnl_onchain_fill_trade
ON pnl_onchain_fill(symbol, chain, tx_hash, log_index);
Loading
Loading