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
49 changes: 48 additions & 1 deletion oracledesk-stellar/agents/bin/trader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, readFile, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
Expand Down Expand Up @@ -54,3 +54,50 @@ test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => {
assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n);
assert.equal(args.deadline, 2_000_030n);
});

test("live mode reads the market and its effective fee from the chain", async () => {
const traceDir = await mkdtemp(join(tmpdir(), "trader-test-"));
const env = { TRACE_STORE_DIR: traceDir, MARKET_ID: "7" };
const reads: Array<[string, bigint]> = [];
const adapter = {
mode: "live" as const,
quoteBuy: async () => QUOTE_SHARES,
agentBuyV2: async (args: Record<string, unknown>) => ({
dryRun: true as const,
contractId: "CTREASURY",
method: "agent_buy_v2",
args,
}),
getMarketView: async (args: { market_id: bigint }) => {
reads.push(["get_market", args.market_id]);
return {
close_time: 2_000_000n,
fee_bps: 100, // the flat snapshot; the schedule may charge more
reserve_yes: 1_000_000_000_000_000n,
reserve_no: 1_000_000_000_000_000n,
};
},
currentFeeBps: async (args: { market_id: bigint }) => {
reads.push(["current_fee_bps", args.market_id]);
return 321; // the dynamic effective fee at the current ledger time
},
} as unknown as StellarAdapter;

await main(adapter, env, () => 1_000_000);

assert.deepEqual(reads, [
["get_market", 7n],
["current_fee_bps", 7n],
]);

// The trace records the live market state, including the effective fee
// (not the flat snapshot) and the reserves the quote was sized against.
const traces = await readdir(traceDir);
assert.equal(traces.length, 1);
const trace = JSON.parse(await readFile(join(traceDir, traces[0]), "utf8")) as {
inputs: { market: { feeBps: number; yesBps: number; reserveYesStroops: string } };
};
assert.equal(trace.inputs.market.feeBps, 321);
assert.equal(trace.inputs.market.yesBps, 5000);
assert.equal(trace.inputs.market.reserveYesStroops, "1000000000000000");
});
259 changes: 119 additions & 140 deletions oracledesk-stellar/agents/bin/trader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env -S npx tsx
/**
* Trader agent, one shot: reads chain state, compares implied price to a
* Trader agent: reads a market's current implied price, compares it to a
* fair-value estimate, and trades the mispricing if the edge clears the
* configured threshold. Every trade is preceded by a recorded trace.
*
Expand All @@ -10,171 +10,150 @@
* backstop, plus a `deadline` — so a stale or sandwiched transaction
* reverts on-chain instead of executing at an arbitrary price.
*
* A thin wrapper over the runtime's trader task (runtime/tasks/trader.ts),
* run exactly once under the single-instance lease; `npm run agentd` runs
* the same task continuously. Defaults to dry-run. Set AGENT_MODE=live and
* TRADER_SECRET_KEY (or the shared AGENT_SECRET_KEY) to submit real testnet
* transactions — see .env.example.
* Defaults to dry-run. Set AGENT_MODE=live and TRADER_SECRET_KEY (or the
* shared AGENT_SECRET_KEY) to submit real testnet transactions — see
* .env.example.
*/
import "dotenv/config";
import { pathToFileURL } from "node:url";
import { EXIT_CONFIG, EXIT_TEMPFAIL, parseAgentMode } from "../runtime/config.js";
import { LeaseHeldError } from "../runtime/lease.js";
import { Runtime } from "../runtime/runtime.js";
import { traderTask } from "../runtime/tasks/trader.js";
import { buildAdapter, createLease, createStateStore, loadDeployments, runtimeOptions, traderDeps } from "../runtime/wiring.js";
import type { StellarAdapter } from "../stellar/adapter.js";

/**
* Exported (and injectable) so tests can drive the whole trade flow with a
* fake adapter and clock. `now` returns unix seconds. An adapter bound to a
* deployment reads markets from chain state; one without (a test fake)
* trades the demo fixture market.
import { buildTrace, type TraceStore } from "../core/trace.js";
import { createTraceStore } from "../core/trace-store-factory.js";
import { BPS } from "../core/fpmm.js";
import { buildTrace, LocalTraceStore } from "../core/trace.js";
import { BPS, priceYesBps } from "../core/fpmm.js";
import { decideTrade, type MarketState } from "../core/strategy.js";
import { agentCredentials, loadDeployments, StellarAdapter } from "../stellar/adapter.js";
import { ChainStateReader } from "../core/state/reader.js";
import { SnapshotCache } from "../core/state/cache.js";
import { tradableMarkets, availableCapital } from "../core/state/select.js";
import { toMarketState } from "../core/state/types.js";
import { Server } from "@stellar/stellar-sdk/rpc";

/**
* Reads the market to trade and the fair value to compare it against.
*
* In live mode the market is a real chain read (`get_market` plus
* `current_fee_bps`) for `MARKET_ID`. The effective fee — not the flat
* `Market.fee_bps` snapshot, which ignores dynamic fee schedules — is fed
* into the strategy's market state so every off-chain quote and impact
* estimate uses the number the contract will actually charge.
*
* The fair-value estimate stays a placeholder: wiring a real
* ReferencePriceSource (and the question lookup it needs) is an open issue —
* see docs/wave-issues.
*/
async function fetchMarketAndFairValue(
adapter: StellarAdapter,
env: NodeJS.ProcessEnv,
now: () => number,
): Promise<{ market: MarketState; fairValueBps: number }> {
if (adapter.mode === "live") {
const marketId = BigInt(env.MARKET_ID ?? "0");
const m = await adapter.getMarketView({ market_id: marketId });
const feeBps = await adapter.currentFeeBps({ market_id: marketId });
return {
market: {
marketId: Number(marketId),
question: "live market",
yesBps: Number(priceYesBps(m.reserve_yes, m.reserve_no)),
closeTime: Number(m.close_time),
reserveYesStroops: m.reserve_yes,
reserveNoStroops: m.reserve_no,
feeBps,
},
fairValueBps: 6000,
};
}
return {
market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: now() + 3600 },
fairValueBps: 6000,
};
}

/**
* Exported (and injectable) so tests can drive the whole trade flow with a
* fake adapter and clock. `now` returns unix seconds. The trace store
* defaults to `createTraceStore(env, adapter.mode)` (TRACE_STORE=local|ipfs).
* fake adapter and clock. `now` returns unix seconds.
*/
export async function main(
adapter: StellarAdapter,
env: NodeJS.ProcessEnv = process.env,
now: () => number = () => Math.floor(Date.now() / 1000),
traceStore: TraceStore = createTraceStore(env, adapter.mode).store,
): Promise<void> {
const deployments = (adapter as Partial<StellarAdapter>).deployments;
const runtime = new Runtime({
tasks: [traderTask(traderDeps(env, adapter, deployments, now), { intervalMs: 0 })],
state: await createStateStore(adapter.mode, env, deployments?.contracts.treasury ?? "unbound"),
lease: createLease(env),
...runtimeOptions(env),
});
await runtime.runOnce();
const deployments = adapter.deployments ?? {};

const rpcUrl = env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org";
const server = new Server(rpcUrl);
const stateReader = new ChainStateReader({
marketContractId: deployments.market_contract ?? "",
portfolioContractId: deployments.portfolio_contract ?? "",
server,
});
const cache = new SnapshotCache();
const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces");

console.log(`[trader] mode=${adapter.mode}`);
const { market, fairValueBps } = await fetchMarketAndFairValue(adapter, env, now);
const decision = decideTrade(market, fairValueBps, {
minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500),
tradeSizeStroops: BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"),
});
console.log(`[trader] ${decision.action}: ${decision.reasoning}`);
if (decision.action === "skip") return;

const built = buildTrace({
agent: "trader",
marketId: market.marketId,
action: "buy",
reasoning: decision.reasoning,
inputs: { market, fairValueBps, decision },
});
const { cid } = await traceStore.put(built.hash, built.json);
console.log(`[trader] trace ${built.hash} -> ${cid}`);

const outcome = { tag: decision.outcome!, values: undefined } as {
tag: "Yes" | "No";
values: void;
};
const collateralInStroops = decision.collateralInStroops!;

// Fresh quote -> non-zero bounds. The quote is a read-only simulation,
// so this runs in dry-run mode too and the same code path is exercised
// before any real submission.
const quotedShares = await adapter.quoteBuy({
market_id: BigInt(market.marketId),
outcome,
collateral_in: collateralInStroops,
});
const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50);
const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS;
// Never pay more than fair for the outcome being bought: for a YES buy
// that is `fairValueBps`; for a NO buy it is the complement, since the
// contract compares the limit against the average price of the outcome
// being bought (the NO price).
const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps;
const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60));
console.log(
`[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`,
);

const result = await adapter.agentBuyV2({
market_id: BigInt(market.marketId),
outcome,
collateral_in: collateralInStroops,
min_shares_out: minSharesOut,
limit_price_bps: limitPriceBps,
deadline,
});
console.log("[trader] agent_buy_v2:", result);

const snapshot = await cache.get(() => stateReader.readSnapshot([]));

if (!snapshot.consistent) {
console.warn(`[trader] ledger drift ${snapshot.ledgers.max - snapshot.ledgers.min} — snapshot may be inconsistent`);
}

const markets = tradableMarkets(snapshot);
if (markets.length === 0) {
console.log("[trader] no tradable markets on-chain");
return;
}

const capital = availableCapital(snapshot);
const tradeSizeStroops = BigInt(env.TRADE_SIZE_STROOPS ?? "100000000");
if (capital < tradeSizeStroops) {
console.log(`[trader] insufficient capital: ${capital} < ${tradeSizeStroops}`);
return;
}

for (const chainMarket of markets) {
const market: MarketState = toMarketState(chainMarket);
// Fair value: use env override or default to complement of current price
// (the agent holds no external oracle right now — strategy.ts decides edge)
const fairValueBps = Number(env.FAIR_VALUE_BPS ?? String(market.yesBps));

const decision = decideTrade(market, fairValueBps, {
minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500),
tradeSizeStroops,
});
console.log(`[trader] market=${market.marketId} ${decision.action}: ${decision.reasoning}`);
if (decision.action === "skip") continue;

const built = buildTrace({
agent: "trader",
marketId: market.marketId,
action: "buy",
reasoning: decision.reasoning,
inputs: { market, fairValueBps, decision },
});
// The trace must be pinned and verified before the trade: never trade
// without a publishable trace.
let cid: string;
try {
({ cid } = await traceStore.put(built.hash, built.json));
} catch (err) {
console.error(`[trader] market=${market.marketId} skipped: trace ${built.hash} could not be stored:`, err);
continue;
}
console.log(`[trader] trace ${built.hash} -> ${cid}`);

const outcome = decision.outcome as "Yes" | "No";
const collateralInStroops = decision.tradeSizeStroops!;

const quotedShares = await adapter.quoteBuy({
market_id: BigInt(market.marketId),
outcome,
collateral_in: collateralInStroops,
});
const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50);
const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS;
const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps;
const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60));
console.log(
`[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`,
);

const result = await adapter.agentBuyV2({
if (!result.dryRun) {
const traceResult = await adapter.publishTrace({
agent: adapter.agent,
market_id: BigInt(market.marketId),
outcome,
collateral_in: collateralInStroops,
min_shares_out: minSharesOut,
limit_price_bps: limitPriceBps,
deadline,
action: "buy",
trace_hash: Buffer.from(built.hash, "hex"),
ipfs_cid: cid,
});
console.log("[trader] agent_buy_v2:", result);

if (!result.dryRun) {
const traceResult = await adapter.publishTrace({
agent: adapter.agent,
market_id: BigInt(market.marketId),
action: "buy",
trace_hash: Buffer.from(built.hash, "hex"),
ipfs_cid: cid,
});
console.log("[trader] publish_trace:", traceResult);
}
console.log("[trader] publish_trace:", traceResult);
}
}

const isMain =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
let mode;
try {
mode = parseAgentMode(process.env);
} catch (err) {
console.error((err as Error).message);
process.exit(EXIT_CONFIG);
}
const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run";
const deployments = loadDeployments();
main(buildAdapter("trader", mode, process.env, deployments)).catch((err) => {
const adapter = new StellarAdapter({
mode,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015",
rpcUrl: process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org",
...agentCredentials("trader", process.env, deployments),
deployments,
});
main(adapter).catch((err) => {
console.error(err);
process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : 1);
process.exit(1);
});
}
Loading