Skip to content
Merged
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
22 changes: 21 additions & 1 deletion packages/strategies/src/backtest/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const hourly = (steps: number) =>
/**
* One USDC price per tick, taken from `prices` in tick order, so a test states
* the path it is testing rather than interpolating between endpoints. EURC is
* carried at a flat price of 1 because the feed wants every asset.
* carried at a flat price of 1 in these cases, though feeds may contain any
* subset of the engine's supported assets.
*/
const feed = (...prices: string[]) =>
BacktestPriceFeed.create({
Expand Down Expand Up @@ -83,6 +84,25 @@ const hold = () => ({
});

describe("runBacktest", () => {
it("runs a strategy with a volatile asset in its portfolio", () => {
const xlmFeed = BacktestPriceFeed.create({
XLM: [
{ timestamp: START, price: "0.10" },
{ timestamp: START + ONE_HOUR_MS, price: "0.12" },
],
});
const result = runBacktest({
clock: hourly(1),
assets: ["XLM"],
prices: xlmFeed,
strategy: new BuyAndHoldStrategy(),
strategyConfig: { asset: "XLM" },
initialCash: amount("1000"),
});

expect(result.snapshots[1]?.positions[0]?.asset).toBe("XLM");
});

it("observes member-tagged fills after they are applied", () => {
const fills: Array<{
asset: AssetSymbol;
Expand Down
6 changes: 4 additions & 2 deletions packages/strategies/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@ export interface SelfRepayingLoanConfig {
readonly collateralSet?: CollateralConfig[];
}

export const KNOWN_ASSETS = new Set([
export const KNOWN_ASSET_SYMBOLS = [
"USDC",
"XLM",
"USDG",
"EURC",
"BTC",
"ETH",
]);
] as const;
export type KnownAssetSymbol = (typeof KNOWN_ASSET_SYMBOLS)[number];
export const KNOWN_ASSETS: ReadonlySet<string> = new Set(KNOWN_ASSET_SYMBOLS);
export const KNOWN_YIELD_SOURCES = new Set([
"blend-pool",
"defindex-vault",
Expand Down
16 changes: 9 additions & 7 deletions packages/strategies/src/feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class StaticPriceFeed implements PriceFeed {
}

static create(
prices: Record<AssetSymbol, string | FixedPointDecimal>
prices: Partial<Record<AssetSymbol, string | FixedPointDecimal>>
): StaticPriceFeed {
const map = new Map<AssetSymbol, FixedPointDecimal>();
for (const [asset, value] of Object.entries(prices)) {
Expand Down Expand Up @@ -119,12 +119,14 @@ export class BacktestPriceFeed implements PriceFeed {
}

static create(
data: Record<
AssetSymbol,
Array<{
timestamp: SimulationTimestamp;
price: string | FixedPointDecimal;
}>
data: Partial<
Record<
AssetSymbol,
Array<{
timestamp: SimulationTimestamp;
price: string | FixedPointDecimal;
}>
>
>,
options?: BacktestPriceFeedOptions
): BacktestPriceFeed {
Expand Down
26 changes: 19 additions & 7 deletions packages/strategies/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,21 @@ describe("StaticPriceFeed", () => {
expect(usdcPrice.equals(eurcPrice)).toBe(false);
});

it("prices non-stablecoin assets through the same feed contract", () => {
const xlmFeed = StaticPriceFeed.create({ XLM: "0.12" });
expect(xlmFeed.getSpotPrice("XLM", BASE_TIMESTAMP).toString()).toBe("0.12");
});

it("throws UnknownAssetError for unknown asset", () => {
expect(() =>
feed.getSpotPrice("BTC" as AssetSymbol, BASE_TIMESTAMP)
feed.getSpotPrice("DOGE" as AssetSymbol, BASE_TIMESTAMP)
).toThrow(UnknownAssetError);
try {
feed.getSpotPrice("BTC" as AssetSymbol, BASE_TIMESTAMP);
feed.getSpotPrice("DOGE" as AssetSymbol, BASE_TIMESTAMP);
} catch (err) {
expect(isUnknownAssetError(err)).toBe(true);
if (isUnknownAssetError(err)) {
expect(err.asset).toBe("BTC");
expect(err.asset).toBe("DOGE");
}
}
});
Expand Down Expand Up @@ -163,12 +168,19 @@ describe("BacktestPriceFeed", () => {
expect(eurcPrice.toString()).toBe("1.08");
});

it("accepts a non-stablecoin asset key", () => {
const xlmFeed = BacktestPriceFeed.create({
XLM: [{ timestamp: BASE_TIMESTAMP, price: "0.12" }],
});
expect(xlmFeed.getSpotPrice("XLM", BASE_TIMESTAMP).toString()).toBe("0.12");
});

it("throws UnknownAssetError for unknown asset", () => {
expect(() =>
feed.getSpotPrice("BTC" as AssetSymbol, BASE_TIMESTAMP)
feed.getSpotPrice("DOGE" as AssetSymbol, BASE_TIMESTAMP)
).toThrow(UnknownAssetError);
try {
feed.getSpotPrice("BTC" as AssetSymbol, BASE_TIMESTAMP);
feed.getSpotPrice("DOGE" as AssetSymbol, BASE_TIMESTAMP);
} catch (err) {
expect(isUnknownAssetError(err)).toBe(true);
}
Expand Down Expand Up @@ -356,7 +368,7 @@ describe("PriceFeed contract tests - multiple implementations", () => {

it("unknown asset throws UnknownAssetError", () => {
expect(() =>
feed.getSpotPrice("BTC" as AssetSymbol, BASE_TIMESTAMP)
feed.getSpotPrice("DOGE" as AssetSymbol, BASE_TIMESTAMP)
).toThrow(UnknownAssetError);
});
});
Expand Down Expand Up @@ -434,7 +446,7 @@ describe("PriceFeed contract tests - multiple implementations", () => {

describe("Error type guards", () => {
it("isUnknownAssetError identifies UnknownAssetError", () => {
const err = new UnknownAssetError("BTC" as AssetSymbol);
const err = new UnknownAssetError("DOGE" as AssetSymbol);
expect(isUnknownAssetError(err)).toBe(true);
expect(isUnknownAssetError(new Error())).toBe(false);
expect(isUnknownAssetError("string")).toBe(false);
Expand Down
14 changes: 5 additions & 9 deletions packages/strategies/src/regime-scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { SUPPORTED_STABLECOINS } from "@meridian/shared";

import { SimulationClock } from "./clock";
import type { DeltaNeutralConfig } from "./delta-neutral";
import { BacktestPriceFeed } from "./feeds";
Expand All @@ -13,6 +11,7 @@ import {
import type { Scenario } from "./scenario";
import { FixedPointDecimal } from "./types";
import type { AssetSymbol, FundingRate, PriceFeed } from "./types";
import { KNOWN_ASSETS } from "./config";

/**
* Synthetic market regimes for scenario backtests (#932).
Expand Down Expand Up @@ -183,7 +182,7 @@ function scenarioAsset(scenario: Scenario): AssetSymbol {
}

function isAssetSymbol(value: string): value is AssetSymbol {
return (SUPPORTED_STABLECOINS as readonly string[]).includes(value);
return KNOWN_ASSETS.has(value);
}

/** The scenario's window and step as a simulation clock. */
Expand Down Expand Up @@ -211,12 +210,9 @@ export function scenarioPriceFeed(scenario: Scenario): BacktestPriceFeed {
seed: seedToBigInt(scenario.seed),
});

// The feed takes every supported asset, and a regime run only reads the one
// the scenario names, so the other series is registered empty.
const series: Record<
AssetSymbol,
Array<{ timestamp: number; price: FixedPointDecimal }>
> = { USDC: [], EURC: [] };
const series: Partial<
Record<AssetSymbol, Array<{ timestamp: number; price: FixedPointDecimal }>>
> = {};
series[asset] = path.map((price, index) => ({
timestamp: clock.timestampAt(index),
price,
Expand Down
4 changes: 2 additions & 2 deletions packages/strategies/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface EventCounts {
// Holdings are per asset expressed as FixedPointDecimal amounts.
export interface ReportPortfolioState {
readonly timestamp: SimulationTimestamp;
readonly holdings: Readonly<Record<AssetSymbol, FixedPointDecimal>>;
readonly holdings: Readonly<Partial<Record<AssetSymbol, FixedPointDecimal>>>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fi-zzy widening holdings to Partial<...> is fine, but the tsc step never got past the build error above, so consumers that index a single asset (for example holdings[asset].toStroops()) have not been typechecked. Please confirm pnpm typecheck passes after the cast is fixed.

// Total portfolio value denominated in the reference asset (USDC).
readonly totalValue: FixedPointDecimal;
}
Expand Down Expand Up @@ -247,7 +247,7 @@ export function deserializeReport(raw: SerializedRunReport): RunReport {
eventCounts: { ...raw.eventCounts },
finalState: {
timestamp: raw.finalState.timestamp,
holdings: holdings as Record<AssetSymbol, FixedPointDecimal>,
holdings: holdings as Partial<Record<AssetSymbol, FixedPointDecimal>>,
totalValue: deserializeFixed(raw.finalState.totalValue),
},
generators: {
Expand Down
12 changes: 12 additions & 0 deletions packages/strategies/src/scenario-backtest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ function series(run: RegimeRun): string {
}

describe("delta-neutral scenario backtests", () => {
it("generates a price series for a non-stablecoin scenario asset", () => {
const scenario = buildRegimeScenario("trending", {
asset: "XLM",
steps: 2,
});
const priceFeed = scenarioPriceFeed(scenario);

expect(
priceFeed.getSpotPrice("XLM", Date.parse(scenario.window.start))
).toBeInstanceOf(FixedPointDecimal);
});

it("runs every regime to completion through the shared runner", () => {
for (const regime of MARKET_REGIMES) {
const { strategy, result } = runRegime(regime);
Expand Down
5 changes: 3 additions & 2 deletions packages/strategies/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SupportedStablecoin } from "@meridian/shared";
import type { KnownAssetSymbol } from "./config";

export const STROOPS_PER_UNIT = 10_000_000n;

Expand Down Expand Up @@ -81,7 +81,8 @@ export class FixedPointDecimal {
}
}

export type AssetSymbol = SupportedStablecoin;
/** Assets the strategy engine can price; distinct from vault deposit assets. */
export type AssetSymbol = KnownAssetSymbol;

export type SimulationTimestamp = number;

Expand Down
Loading