Skip to content
Open
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
8 changes: 8 additions & 0 deletions oracledesk-stellar/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ TESTNET_FACILITATOR_API_KEY=
# --- Agents (agents/) -------------------------------------------------------
# "dry-run" (default, never signs or submits) or "live" (real testnet txs).
AGENT_MODE=dry-run
# Market the trader agent reads and trades (see agents/stellar/adapter.ts).
MARKET_ID=0
# Which price the trader compares to its fair-value estimate:
# spot = market-core's instantaneous `get_price` (moves with any trade)
# twap = the manipulation-resistant time-weighted average over
# TWAP_WINDOW_SECS, read via market-core's `twap` view
PRICE_SOURCE=spot
TWAP_WINDOW_SECS=900
# Signing keys (S...). Each CLI prefers its own role key and falls back to
# the shared AGENT_SECRET_KEY (the constructor agent, which has every role):
# bin/market-maker.ts reads MAKER_SECRET_KEY, bin/trader.ts TRADER_SECRET_KEY.
Expand Down
68 changes: 67 additions & 1 deletion oracledesk-stellar/agents/core/strategy.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { calcBuy, priceAfterBuy } from "./fpmm.js";
import { decideMarketProposal, decideTrade } from "./strategy.js";
import { decideMarketProposal, decideTrade, twapFromObservations } from "./strategy.js";

test("decideMarketProposal skips a signal with no actionable estimate", () => {
const d = decideMarketProposal(
Expand Down Expand Up @@ -70,6 +70,72 @@ test("decideTrade buys No when the market overprices Yes", () => {
assert.equal(d.outcome, "No");
});

test("decideTrade uses twapBps when priceSource is twap", () => {
// Spot sits right on the fair value (no edge), but the TWAP reveals the
// real mispricing the spot price was dragged away from.
const d = decideTrade(
{ marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 },
6000,
{ minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" },
);
assert.equal(d.action, "buy");
assert.equal(d.outcome, "Yes");
assert.match(d.reasoning, /4000bps \(twap\)/);

// The same market read as spot skips.
const spot = decideTrade(
{ marketId: 7, question: "q", yesBps: 6000, twapBps: 4000, closeTime: 0 },
6000,
{ minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "spot" },
);
assert.equal(spot.action, "skip");
});

test("decideTrade with priceSource twap requires a twap price", () => {
assert.throws(
() =>
decideTrade(
{ marketId: 7, question: "q", yesBps: 6000, closeTime: 0 },
6000,
{ minEdgeBps: 100, tradeSizeStroops: 10_000_000n, priceSource: "twap" },
),
/twapBps/,
);
});

test("twapFromObservations matches contract example", () => {
// Mirrors market-core's `twap_weights_by_time` arithmetic with round
// numbers: seeded at 6800bps, trade at t=1000 leaving 5000bps, trade at
// t=1100 leaving 6000bps, then the live edge counterfactually extended to
// t=1400. The 400s window from t=1000 to t=1400 averages
// (5000*100 + 6000*300) / 400 = 5750.
const observations = [
{ ts: 0, cumulative: 0n },
{ ts: 1000, cumulative: 6_800_000n },
{ ts: 1100, cumulative: 6_800_000n + 100n * 5_000n },
{ ts: 1400, cumulative: 6_800_000n + 100n * 5_000n + 300n * 6_000n },
];
assert.equal(twapFromObservations(observations, 400), 5750);
// The last 300s were held at 6000bps exactly.
assert.equal(twapFromObservations(observations, 300), 6000);
// Windows may start strictly between observations (interpolated chord):
// t = 950 sits in the seeded-price stretch, so cum(950) = 6800 * 950.
assert.equal(
twapFromObservations(observations, 450),
Math.floor((9_100_000 - (6_800_000 * 950) / 1000) / 450),
);
});

test("twapFromObservations throws when the window predates the ring", () => {
const observations = [
{ ts: 1000, cumulative: 0n },
{ ts: 2000, cumulative: 5_000_000n },
];
assert.throws(() => twapFromObservations(observations, 1001), /ObservationTooOld/);
assert.throws(() => twapFromObservations([], 100), /no observations/);
assert.throws(() => twapFromObservations(observations, 0), /positive integer/);
});

// The 100-sets pool at 50% with a 1% fee used by the calcBuy reference
// example: 10 USDC moves the price 470bps, so a 200bps cap must clamp well
// below the requested size.
Expand Down
106 changes: 101 additions & 5 deletions oracledesk-stellar/agents/core/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export function decideMarketProposal(
export interface MarketState {
marketId: number;
question: string;
yesBps: number; // current market-implied probability
yesBps: number; // current market-implied probability (spot)
/** Time-weighted average YES price in bps, when the caller read it
* (market-core's `twap`). Required by `decideTrade` when `priceSource` is
* `"twap"`; spot reads may leave it undefined. */
twapBps?: number;
closeTime: number;
/** Pool reserves in stroops, if known. Required when the caller sets
* `maxImpactBps` on `decideTrade`. */
Expand Down Expand Up @@ -160,6 +164,12 @@ function clampSizeToImpact(
* decides whether the mispricing is worth trading. Trades toward whichever
* side the market is *underpricing* relative to the estimate.
*
* `priceSource` picks which of the market's prices is compared: `"spot"`
* (default) uses `MarketState.yesBps`, which a single trade can move
* arbitrarily within one ledger; `"twap"` uses `MarketState.twapBps`, the
* manipulation-resistant time-weighted average. Prefer `"twap"` for
* automated trading loops.
*
* With `maxImpactBps` set to a positive value, the trade size is clamped
* so the estimated price impact stays below the market's on-chain breaker
* (the market state must then carry reserves and `feeBps`); if even the
Expand All @@ -170,13 +180,31 @@ function clampSizeToImpact(
export function decideTrade(
market: MarketState,
fairValueBps: number,
opts: { minEdgeBps: number; tradeSizeStroops: bigint; maxImpactBps?: number },
opts: {
minEdgeBps: number;
tradeSizeStroops: bigint;
priceSource?: "spot" | "twap";
maxImpactBps?: number;
},
): TraderDecision {
const edge = fairValueBps - market.yesBps; // positive => YES underpriced
const priceSource = opts.priceSource ?? "spot";
let marketBps: number;
if (priceSource === "twap") {
if (market.twapBps === undefined) {
throw new Error(
`priceSource "twap" requires market.twapBps on market ${market.marketId} — read it with readMarket({ useTwapSecs })`,
);
}
marketBps = market.twapBps;
} else {
marketBps = market.yesBps;
}

const edge = fairValueBps - marketBps; // positive => YES underpriced
if (Math.abs(edge) < opts.minEdgeBps) {
return {
action: "skip",
reasoning: `edge ${edge}bps on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`,
reasoning: `edge ${edge}bps (${priceSource}) on market ${market.marketId} is below the ${opts.minEdgeBps}bps threshold`,
};
}
const outcome = edge > 0 ? "Yes" : "No";
Expand All @@ -196,8 +224,76 @@ export function decideTrade(
}
return {
action: "buy",
reasoning: `market ${market.marketId} prices YES at ${market.yesBps}bps vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}${clampNote}`,
reasoning: `market ${market.marketId} prices YES at ${marketBps}bps (${priceSource}) vs fair value ${fairValueBps}bps (edge ${edge}bps) — buying ${outcome}${clampNote}`,
outcome,
collateralInStroops,
};
}

/** One point of market-core's TWAP ring, as returned by the `Obs` entries
* (or the `observe` view): the cumulative YES-price-seconds at `ts`. */
export interface PriceObservation {
/** unix seconds */
ts: number;
/** `price_cumulative` (bps × seconds) as of `ts` */
cumulative: bigint;
}

/**
* BigInt mirror of market-core's `observe`/`twap` math, for consumers that
* cannot simulate the contract (e.g. rendering historical charts from
* indexed observations). Linearly interpolates the cumulative between
* observations — exact there, because the accumulator is linear on each
* interval (its slope is the price that held) — evaluates the window ending
* at the newest observation's timestamp, and floors, exactly like the
* contract.
*
* The contract counterfactually extends the accumulator from the last trade
* to the current time (the last price holds). To reproduce that, append the
* live edge — `(last_ts, price_cumulative)` is what `observe(marketId, 0)`
* returns — as the final observation.
*
* Throws a `RangeError` when the window reaches before the first
* observation (the contract's `ObservationTooOld`), or when the inputs are
* degenerate. `observations` must be ordered by non-decreasing `ts`; exact
* duplicates (same ts and cumulative) are tolerated.
*/
export function twapFromObservations(
observations: readonly PriceObservation[],
windowSecs: number,
): number {
if (observations.length === 0) {
throw new RangeError("twapFromObservations: no observations");
}
if (!Number.isInteger(windowSecs) || windowSecs <= 0) {
throw new RangeError(`twapFromObservations: window must be a positive integer, got ${windowSecs}`);
}
const now = observations[observations.length - 1].ts;
const t = now - windowSecs;
if (t < observations[0].ts) {
throw new RangeError("twapFromObservations: window reaches before the oldest observation (ObservationTooOld)");
}
const delta = cumulativeAt(observations, now) - cumulativeAt(observations, t);
return Number(delta / BigInt(windowSecs));
}

/** Newest observation with `ts <= t`, interpolated along the next chord if
* `t` falls strictly between two observations. */
function cumulativeAt(observations: readonly PriceObservation[], t: number): bigint {
let lo = 0;
let hi = observations.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (observations[mid].ts <= t) {
lo = mid;
} else {
hi = mid - 1;
}
}
const a = observations[lo];
if (a.ts === t) {
return a.cumulative;
}
const b = observations[lo + 1];
return a.cumulative + ((b.cumulative - a.cumulative) * BigInt(t - a.ts)) / BigInt(b.ts - a.ts);
}
61 changes: 61 additions & 0 deletions oracledesk-stellar/agents/stellar/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ export type LiveResult<T> = { dryRun: false; value: T };

export type AdapterResult<T> = DryRunResult | LiveResult<T>;

/** Snapshot of a market's read-only state, from `readMarket`. */
export interface MarketRead {
marketId: number;
/** IPFS CID / URI of the question metadata (the question text itself is
* stored off-chain; see docs/frontend-integration.md). */
metaUri: string;
closeTime: number;
reserveYes: bigint;
reserveNo: bigint;
/** YES spot price in bps (0..=10_000), from `get_price`. */
yesBps: number;
/** YES time-weighted average over the requested `useTwapSecs` window,
* from `twap`. Present only when `useTwapSecs` was passed. */
twapBps?: number;
}

/**
* The call failed before a transaction was submitted (building or
* simulating it — e.g. a treasury cap rejection), so it certainly has no
Expand Down Expand Up @@ -428,6 +444,51 @@ export class StellarAdapter {
(client) => client.publish_trace_signed(call),
);
}

/**
* Reads a market's price state. Unlike the write wrappers above, a read
* always queries the network — through the bindings' simulation, which
* never signs or submits — so it works identically in dry-run and live
* mode and there is no offline stand-in to return.
*
* `useTwapSecs` additionally reads `twap(market_id, useTwapSecs)`: the
* time-weighted average over that trailing window, which a single
* transaction cannot move (see market-core's accumulator, issue #11).
* Prefer it over spot for any automated trading loop.
*/
async readMarket(
marketId: bigint,
opts: { useTwapSecs?: number } = {},
): Promise<MarketRead> {
const client = await this.client<any>("market_core", "@oracledesk/bindings-market-core");
const marketTx = await client.get_market({ market_id: marketId });
const market = marketTx.result.unwrap() as {
meta_uri: string;
close_time: bigint;
reserve_yes: bigint;
reserve_no: bigint;
};
const priceTx = await client.get_price({
market_id: marketId,
outcome: { tag: "Yes", values: undefined },
});
const read: MarketRead = {
marketId: Number(marketId),
metaUri: market.meta_uri,
closeTime: Number(market.close_time), // unix seconds, safe as number
reserveYes: market.reserve_yes,
reserveNo: market.reserve_no,
yesBps: priceTx.result.unwrap() as number,
};
if (opts.useTwapSecs !== undefined) {
const twapTx = await client.twap({
market_id: marketId,
window_secs: BigInt(opts.useTwapSecs),
});
read.twapBps = twapTx.result.unwrap() as number;
}
return read;
}
}

/**
Expand Down
Loading