diff --git a/apps/docs/strategies/composition.md b/apps/docs/strategies/composition.md index b7069e36..2f002ea1 100644 --- a/apps/docs/strategies/composition.md +++ b/apps/docs/strategies/composition.md @@ -55,6 +55,36 @@ using `/` (e.g. `outer/inner`). Each member receives a `MemberPortfolioView` containing only its own positions, while cash and total portfolio valuation remain consistent views of the shared ledger. +### 1.3 Composition constraints + +`ComposedStrategy` optionally accepts composition-wide limits. The validator +checks the post-trade composition, meaning current positions plus the orders +returned by members at current marks. + +```typescript +const composed = new ComposedStrategy(members, { + constraints: { + maxMemberCount: 4, + maxMemberWeight: FixedPointDecimal.fromString("0.6"), + maxAggregateExposure: FixedPointDecimal.fromString("0.5"), + minCashBuffer: FixedPointDecimal.fromString("0.1"), + }, + mode: "reject", +}); +``` + +| Constraint | Meaning | +| ---------------------- | -------------------------------------------------------------- | +| `maxMemberWeight` | Maximum share of total value in one member's gross positions | +| `maxAggregateExposure` | Maximum share of total value in one asset across all members | +| `maxMemberCount` | Maximum number of members | +| `minCashBuffer` | Minimum share of total value that remains in cash after orders | + +`mode` defaults to `reject`, which throws `CompositionConstraintError` and +names every violation. `mode: "clamp"` trims aggregate-exposure orders to the +available limit. Member-count, member-weight, cash-buffer, and already-breached +exposure limits are rejected because trimming orders cannot guarantee them. + ## 2. Capital allocation logic When initializing members, capital is distributed across members according to @@ -62,10 +92,9 @@ their normalized target weights. ### 2.1 Apportionment and exact capital conservation -In current versions, `ComposedStrategy` initializes each member using -`startingCapital.mul(member.allocation)`, while `allocateCapital` is available -as a standalone helper in `@meridian/strategies` to compute exact capital -conservation using Hamilton's largest-remainder apportionment rule: +`ComposedStrategy` allocates its starting capital with `allocateCapital`, +which uses Hamilton's largest-remainder apportionment to compute exact capital +conservation: $$\sum \text{Member Capital} = \text{Total Initial Capital}$$ diff --git a/packages/strategies/src/composition/constraint-engine.test.ts b/packages/strategies/src/composition/constraint-engine.test.ts new file mode 100644 index 00000000..28a1238b --- /dev/null +++ b/packages/strategies/src/composition/constraint-engine.test.ts @@ -0,0 +1,591 @@ +import { describe, it, expect } from "vitest"; +import { + validateComposition, + enforceReject, + enforceClamp, + CompositionConstraintError, + isCompositionConstraintError, + type MemberView, +} from "./constraint-engine"; +import { FixedPointDecimal, UnknownAssetError } from "../types"; +import type { AssetSymbol } from "../types"; +import type { PortfolioView, PositionView } from "../strategy"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const D = (v: string) => FixedPointDecimal.fromString(v); +const USDC: AssetSymbol = "USDC"; +const EURC: AssetSymbol = "EURC"; + +function makePortfolio( + cash: string, + positions: Array<{ asset: AssetSymbol; size: string; memberId?: string }> +): PortfolioView { + const posViews: PositionView[] = positions.map((p) => ({ + asset: p.asset, + size: D(p.size), + basis: D("0"), + ...(p.memberId === undefined ? {} : { memberId: p.memberId }), + })); + + return { + cash: D(cash), + getPosition(asset: AssetSymbol) { + return posViews.find((p) => p.asset === asset); + }, + positions() { + return posViews; + }, + totalValue(prices: ReadonlyMap) { + let total = D(cash); + for (const pos of posViews) { + const price = prices.get(pos.asset) ?? D("0"); + total = total.add(pos.size.mul(price)); + } + return total; + }, + }; +} + +function makeMember( + id: string, + cash: string, + positions: Array<{ asset: AssetSymbol; size: string }> +): MemberView { + const posViews: PositionView[] = positions.map((p) => ({ + asset: p.asset, + size: D(p.size), + basis: D("0"), + memberId: id, + })); + const portfolio: PortfolioView = { + cash: D(cash), + getPosition(asset: AssetSymbol) { + return posViews.find((p) => p.asset === asset); + }, + positions() { + return posViews; + }, + totalValue(prices: ReadonlyMap) { + let total = D(cash); + for (const pos of posViews) { + const price = prices.get(pos.asset) ?? D("0"); + total = total.add(pos.size.mul(price)); + } + return total; + }, + }; + return { id, portfolio }; +} + +const PRICES: ReadonlyMap = new Map([ + [USDC, D("1")], + [EURC, D("1")], +]); + +// --------------------------------------------------------------------------- +// validateComposition – member-count +// --------------------------------------------------------------------------- + +describe("validateComposition – member-count", () => { + it("passes when member count is within limit", () => { + const members = [makeMember("a", "100", []), makeMember("b", "100", [])]; + const portfolio = makePortfolio("200", []); + const result = validateComposition( + { maxMemberCount: 3 }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(true); + expect(result.violations).toHaveLength(0); + }); + + it("flags a violation when member count exceeds limit", () => { + const members = [ + makeMember("a", "100", []), + makeMember("b", "100", []), + makeMember("c", "100", []), + ]; + const portfolio = makePortfolio("300", []); + const result = validateComposition( + { maxMemberCount: 2 }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(false); + const v = result.violations[0]; + expect(v?.kind).toBe("member-count"); + if (v?.kind === "member-count") { + expect(v.actual).toBe(3); + expect(v.limit).toBe(2); + } + }); +}); + +// --------------------------------------------------------------------------- +// validateComposition – member-weight +// --------------------------------------------------------------------------- + +describe("validateComposition – member-weight", () => { + it("passes when all members are within the weight limit", () => { + const members = [ + makeMember("a", "0", [{ asset: USDC, size: "500" }]), + makeMember("b", "0", [{ asset: USDC, size: "500" }]), + ]; + const portfolio = makePortfolio("0", [{ asset: USDC, size: "1000" }]); + const result = validateComposition( + { maxMemberWeight: D("0.6") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(true); + }); + + it("flags the offending member when its weight exceeds the limit", () => { + const members = [ + makeMember("heavy", "0", [{ asset: USDC, size: "800" }]), + makeMember("light", "0", [{ asset: USDC, size: "200" }]), + ]; + const portfolio = makePortfolio("0", [{ asset: USDC, size: "1000" }]); + const result = validateComposition( + { maxMemberWeight: D("0.7") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(false); + const v = result.violations[0]; + expect(v?.kind).toBe("member-weight"); + if (v?.kind === "member-weight") { + expect(v.memberId).toBe("heavy"); + expect(v.limit.toString()).toBe("0.7"); + } + }); +}); + +// --------------------------------------------------------------------------- +// validateComposition – aggregate-exposure +// --------------------------------------------------------------------------- + +describe("validateComposition – aggregate-exposure", () => { + it("passes when aggregate exposure per asset is within limit", () => { + const members = [ + makeMember("a", "0", [{ asset: USDC, size: "300" }]), + makeMember("b", "0", [{ asset: USDC, size: "200" }]), + ]; + const portfolio = makePortfolio("500", [{ asset: USDC, size: "500" }]); + // total value = 500 cash + 500 USDC = 1000 + // aggregate USDC exposure = (300 + 200) / 1000 = 0.5 + const result = validateComposition( + { maxAggregateExposure: D("0.6") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(true); + }); + + it("detects aggregate exposure across members that no single member shows", () => { + // Each member alone looks fine (30% each), but together they're at 60%. + const members = [ + makeMember("a", "0", [{ asset: USDC, size: "300" }]), + makeMember("b", "0", [{ asset: USDC, size: "300" }]), + ]; + const portfolio = makePortfolio("400", [{ asset: USDC, size: "600" }]); + // total value = 400 + 600 = 1000, aggregate USDC = 600/1000 = 0.6 + const result = validateComposition( + { maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(false); + const v = result.violations[0]; + expect(v?.kind).toBe("aggregate-exposure"); + if (v?.kind === "aggregate-exposure") { + expect(v.asset).toBe(USDC); + expect(v.limit.toString()).toBe("0.5"); + } + }); + + it("reports each asset that breaches the limit independently", () => { + const members = [ + makeMember("a", "0", [ + { asset: USDC, size: "600" }, + { asset: EURC, size: "600" }, + ]), + ]; + const portfolio = makePortfolio("0", [ + { asset: USDC, size: "600" }, + { asset: EURC, size: "600" }, + ]); + // total = 1200, each asset = 600/1200 = 0.5 → both exceed 0.4 + const result = validateComposition( + { maxAggregateExposure: D("0.4") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(false); + const kinds = result.violations.map((v) => v.kind); + expect(kinds.filter((k) => k === "aggregate-exposure")).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// validateComposition – cash-buffer +// --------------------------------------------------------------------------- + +describe("validateComposition – cash-buffer", () => { + it("passes when cash buffer meets the minimum", () => { + const members = [makeMember("a", "200", [{ asset: USDC, size: "800" }])]; + const portfolio = makePortfolio("200", [{ asset: USDC, size: "800" }]); + // total = 1000, cash = 200, buffer = 0.2 + const result = validateComposition( + { minCashBuffer: D("0.1") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(true); + }); + + it("flags a violation when cash buffer falls below minimum", () => { + const members = [makeMember("a", "50", [{ asset: USDC, size: "950" }])]; + const portfolio = makePortfolio("50", [{ asset: USDC, size: "950" }]); + const orders = [{ asset: USDC, size: D("10") }]; + // total = 1000, projected cash = 40, buffer = 0.04 < 0.1 + const result = validateComposition( + { minCashBuffer: D("0.1") }, + members, + portfolio, + PRICES, + orders + ); + expect(result.valid).toBe(false); + const v = result.violations[0]; + expect(v?.kind).toBe("cash-buffer"); + if (v?.kind === "cash-buffer") { + expect(v.limit.toString()).toBe("0.1"); + } + expect(() => enforceReject(result)).toThrow(/cash buffer/); + }); +}); + +describe("validateComposition – member-weight orders", () => { + it("counts proposed orders toward the member's weight", () => { + const members = [ + makeMember("heavy", "1000", []), + makeMember("light", "1000", []), + ]; + const portfolio = makePortfolio("1000", []); + const result = validateComposition( + { maxMemberWeight: D("0.7") }, + members, + portfolio, + PRICES, + [{ asset: USDC, size: D("800"), memberId: "heavy" }] + ); + + expect(result.valid).toBe(false); + expect(result.violations[0]?.kind).toBe("member-weight"); + }); + + it("rejects non-positive prices before computing weights", () => { + const members = [makeMember("a", "0", [{ asset: USDC, size: "100" }])]; + const portfolio = makePortfolio("1000", []); + + expect(() => + validateComposition( + { maxMemberWeight: D("0.5") }, + members, + portfolio, + new Map([[USDC, D("0")]]) + ) + ).toThrow(RangeError); + }); +}); + +// --------------------------------------------------------------------------- +// enforceReject +// --------------------------------------------------------------------------- + +describe("enforceReject", () => { + it("does nothing when the composition is valid", () => { + expect(() => enforceReject({ valid: true, violations: [] })).not.toThrow(); + }); + + it("throws a CompositionConstraintError naming the violated constraint", () => { + const members = [ + makeMember("a", "0", [{ asset: USDC, size: "300" }]), + makeMember("b", "0", [{ asset: USDC, size: "300" }]), + ]; + const portfolio = makePortfolio("400", [{ asset: USDC, size: "600" }]); + const result = validateComposition( + { maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES + ); + + expect(() => enforceReject(result)).toThrow(CompositionConstraintError); + + try { + enforceReject(result); + } catch (error) { + expect(isCompositionConstraintError(error)).toBe(true); + if (isCompositionConstraintError(error)) { + expect(error.reason).toBe("constraint-violated"); + expect(error.violations.length).toBeGreaterThan(0); + expect(error.message).toContain("USDC"); + } + } + }); + + it("includes all violations in the thrown error", () => { + const members = [ + makeMember("heavy", "0", [{ asset: USDC, size: "900" }]), + makeMember("light", "0", [{ asset: USDC, size: "100" }]), + ]; + const portfolio = makePortfolio("0", [{ asset: USDC, size: "1000" }]); + const result = validateComposition( + { maxMemberWeight: D("0.5"), maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES + ); + + try { + enforceReject(result); + } catch (error) { + if (isCompositionConstraintError(error)) { + expect(error.violations.length).toBeGreaterThanOrEqual(2); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// enforceClamp +// --------------------------------------------------------------------------- + +describe("enforceClamp", () => { + it("refuses to clamp when total value is non-positive", () => { + const result = { + valid: false, + violations: [ + { + kind: "aggregate-exposure" as const, + asset: USDC, + actual: D("1"), + limit: D("0.5"), + }, + ], + }; + + expect(() => + enforceClamp(result, [], PRICES, D("0"), makePortfolio("0", [])) + ).toThrow(CompositionConstraintError); + }); + + it("returns orders unchanged when the composition is valid", () => { + const orders = [{ asset: USDC, size: D("100") }]; + const portfolio = makePortfolio("900", [{ asset: USDC, size: "100" }]); + const result = enforceClamp( + { valid: true, violations: [] }, + orders, + PRICES, + D("1000"), + portfolio + ); + expect(result.orders).toHaveLength(1); + expect(result.orders[0]?.size.toString()).toBe("100"); + expect(result.clampedViolations).toHaveLength(0); + }); + + it("scales down the increasing orders to the available exposure", () => { + // current exposure 300, total value 1000, limit 60% -> target 600 + // proposed buy 400 would reach 700, so clamp it to 300 + const orders = [{ asset: USDC, size: D("400") }]; + const members = [makeMember("a", "0", [{ asset: USDC, size: "300" }])]; + const portfolio = makePortfolio("700", [{ asset: USDC, size: "300" }]); + const validationResult = validateComposition( + { maxAggregateExposure: D("0.6") }, + members, + portfolio, + PRICES, + orders + ); + expect(validationResult.valid).toBe(false); + + const clampResult = enforceClamp( + validationResult, + orders, + PRICES, + D("1000"), + portfolio + ); + + expect(clampResult.orders[0]?.size.toString()).toBe("300"); + expect(clampResult.clampedViolations).toHaveLength(1); + expect(clampResult.clampedViolations[0]?.kind).toBe("aggregate-exposure"); + const postTrade = D("300").add(clampResult.orders[0]!.size); + expect(postTrade.toString()).toBe("600"); + }); + + it("throws when a non-order violation cannot be fixed by clamping", () => { + const members = [ + makeMember("a", "100", []), + makeMember("b", "100", []), + makeMember("c", "100", []), + ]; + const portfolio = makePortfolio("300", []); + const result = validateComposition( + { maxMemberCount: 2 }, + members, + portfolio, + PRICES + ); + + const orders = [{ asset: USDC, size: D("50") }]; + expect(() => + enforceClamp(result, orders, PRICES, D("300"), portfolio) + ).toThrow(CompositionConstraintError); + }); + + it("throws when existing exposure already breaches the limit", () => { + const orders = [{ asset: USDC, size: D("100") }]; + const members = [makeMember("a", "0", [{ asset: USDC, size: "600" }])]; + const portfolio = makePortfolio("400", [{ asset: USDC, size: "600" }]); + const result = validateComposition( + { maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES, + orders + ); + + expect(() => + enforceClamp(result, orders, PRICES, D("1000"), portfolio) + ).toThrow(/current exposure already breaches/); + }); +}); + +// --------------------------------------------------------------------------- +// Invalid constraint definitions +// --------------------------------------------------------------------------- + +describe("invalid constraint definitions", () => { + const members = [makeMember("a", "100", [])]; + const portfolio = makePortfolio("100", []); + + it("throws when maxMemberWeight is zero", () => { + expect(() => + validateComposition( + { maxMemberWeight: D("0") }, + members, + portfolio, + PRICES + ) + ).toThrow(CompositionConstraintError); + }); + + it("throws when maxMemberWeight exceeds 1", () => { + expect(() => + validateComposition( + { maxMemberWeight: D("1.0000001") }, + members, + portfolio, + PRICES + ) + ).toThrow(CompositionConstraintError); + }); + + it("throws when maxAggregateExposure is zero", () => { + expect(() => + validateComposition( + { maxAggregateExposure: D("0") }, + members, + portfolio, + PRICES + ) + ).toThrow(CompositionConstraintError); + }); + + it("throws when maxMemberCount is less than 1", () => { + expect(() => + validateComposition({ maxMemberCount: 0 }, members, portfolio, PRICES) + ).toThrow(CompositionConstraintError); + }); + + it("throws when minCashBuffer is negative", () => { + expect(() => + validateComposition( + { minCashBuffer: D("-0.1") }, + members, + portfolio, + PRICES + ) + ).toThrow(CompositionConstraintError); + }); + + it("throws when minCashBuffer equals 1", () => { + expect(() => + validateComposition({ minCashBuffer: D("1") }, members, portfolio, PRICES) + ).toThrow(CompositionConstraintError); + }); + + it("isCompositionConstraintError returns false for unrelated errors", () => { + expect(isCompositionConstraintError(new RangeError("nope"))).toBe(false); + expect(isCompositionConstraintError("not an error")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +describe("edge cases", () => { + it("skips weight and exposure checks when total value is zero", () => { + const members = [makeMember("a", "0", [])]; + const portfolio = makePortfolio("0", []); + const result = validateComposition( + { maxMemberWeight: D("0.5"), maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES + ); + expect(result.valid).toBe(true); + }); + + it("returns valid when no constraints are defined", () => { + const members = [makeMember("a", "0", [{ asset: USDC, size: "1000" }])]; + const portfolio = makePortfolio("0", [{ asset: USDC, size: "1000" }]); + const result = validateComposition({}, members, portfolio, PRICES); + expect(result.valid).toBe(true); + expect(result.violations).toHaveLength(0); + }); + + it("throws when a held asset has no price", () => { + const noPrices: ReadonlyMap = new Map([ + [USDC, D("1")], + // EURC has no price + ]); + const members = [makeMember("a", "0", [{ asset: EURC, size: "1000" }])]; + const portfolio = makePortfolio("1000", [{ asset: EURC, size: "1000" }]); + + expect(() => + validateComposition( + { maxAggregateExposure: D("0.1") }, + members, + portfolio, + noPrices + ) + ).toThrow(UnknownAssetError); + }); +}); diff --git a/packages/strategies/src/composition/constraint-engine.ts b/packages/strategies/src/composition/constraint-engine.ts new file mode 100644 index 00000000..b49a565c --- /dev/null +++ b/packages/strategies/src/composition/constraint-engine.ts @@ -0,0 +1,636 @@ +/** + * Composition constraint engine (#1199). + * + * A composition is exactly where hidden concentration appears: the same asset + * can be reached through several members at once, but no single member shows + * the combined exposure. This module validates a composition against explicit + * limits and returns typed violations so the caller can either reject the + * composition or clamp it before applying member orders. + * + * Two enforcement policies are supported: + * - "reject": throw a `CompositionConstraintError` naming each violation. + * - "clamp": reduce offending orders before they are applied and return a + * `ClampResult` describing what was trimmed. + * + * The validator evaluates the post-trade composition: current positions plus + * any proposed orders at the current marks. Members never mutate one another's + * orders; the composition applies the policy when merging member orders. + */ +import { FixedPointDecimal, UnknownAssetError } from "../types"; +import type { AssetSymbol } from "../types"; +import type { Order, PortfolioView } from "../strategy"; + +const UNIT = 10_000_000n; + +// --------------------------------------------------------------------------- +// Constraints +// --------------------------------------------------------------------------- + +/** + * Declarative limits on a composition. All fields are optional; supply only + * the constraints that matter for your composition. + */ +export interface CompositionConstraints { + /** + * Maximum fraction of total portfolio value held as one member's gross + * position exposure (0 < maxMemberWeight ≤ 1). Checked after rebalance. + */ + readonly maxMemberWeight?: FixedPointDecimal | undefined; + + /** + * Maximum aggregate exposure per asset across all members, expressed as a + * fraction of total portfolio value (0 < maxAggregateExposure ≤ 1). This + * is the key check a per-member limit cannot provide. + */ + readonly maxAggregateExposure?: FixedPointDecimal | undefined; + + /** + * Maximum number of members in the composition. Checked at init. + */ + readonly maxMemberCount?: number | undefined; + + /** + * Minimum cash buffer as a fraction of total portfolio value that must + * remain undeployed after rebalance (0 ≤ minCashBuffer < 1). + */ + readonly minCashBuffer?: FixedPointDecimal | undefined; +} + +// --------------------------------------------------------------------------- +// Violation types +// --------------------------------------------------------------------------- + +export type ConstraintViolationKind = + "member-weight" | "aggregate-exposure" | "member-count" | "cash-buffer"; + +export interface MemberWeightViolation { + readonly kind: "member-weight"; + readonly memberId: string; + readonly actual: FixedPointDecimal; + readonly limit: FixedPointDecimal; +} + +export interface AggregateExposureViolation { + readonly kind: "aggregate-exposure"; + readonly asset: AssetSymbol; + readonly actual: FixedPointDecimal; + readonly limit: FixedPointDecimal; +} + +export interface MemberCountViolation { + readonly kind: "member-count"; + readonly actual: number; + readonly limit: number; +} + +export interface CashBufferViolation { + readonly kind: "cash-buffer"; + readonly actual: FixedPointDecimal; + readonly limit: FixedPointDecimal; +} + +export type ConstraintViolation = + | MemberWeightViolation + | AggregateExposureViolation + | MemberCountViolation + | CashBufferViolation; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export type CompositionConstraintErrorReason = + | "invalid-max-member-weight" + | "invalid-max-aggregate-exposure" + | "invalid-max-member-count" + | "invalid-min-cash-buffer" + | "constraint-violated"; + +export class CompositionConstraintError extends Error { + readonly reason: CompositionConstraintErrorReason; + readonly violations: readonly ConstraintViolation[]; + + constructor( + reason: CompositionConstraintErrorReason, + message: string, + violations: readonly ConstraintViolation[] = [] + ) { + super(message); + this.name = "CompositionConstraintError"; + this.reason = reason; + this.violations = violations; + } +} + +export function isCompositionConstraintError( + error: unknown +): error is CompositionConstraintError { + return error instanceof CompositionConstraintError; +} + +// --------------------------------------------------------------------------- +// Portfolio view helpers +// --------------------------------------------------------------------------- + +/** Per-member view needed for weight checking. */ +export interface MemberView { + readonly id: string; + readonly portfolio: PortfolioView; +} + +// --------------------------------------------------------------------------- +// Validation result +// --------------------------------------------------------------------------- + +export interface ValidationResult { + /** All violations found. Empty when the composition is valid. */ + readonly violations: readonly ConstraintViolation[]; + /** True when `violations` is empty. */ + readonly valid: boolean; +} + +// --------------------------------------------------------------------------- +// Core validator +// --------------------------------------------------------------------------- + +/** + * Pure validator. Computes post-trade gross exposure across members and checks + * every enabled constraint. Returns every violation found; the caller chooses + * the enforcement policy (reject or clamp). + * + * @param constraints - The limits to enforce. + * @param members - Per-member portfolio views (must be non-empty). + * @param portfolio - The shared portfolio view (for cash and total value). + * @param prices - Current prices, used to compute portfolio value. + * @param proposedOrders - Orders that have not been applied yet. + * @throws {CompositionConstraintError} when a constraint definition is itself + * invalid (e.g. maxMemberWeight > 1). + * @throws {UnknownAssetError} when a held or ordered asset has no price. + */ +export function validateComposition( + constraints: CompositionConstraints, + members: readonly MemberView[], + portfolio: PortfolioView, + prices: ReadonlyMap, + proposedOrders: readonly Order[] = [] +): ValidationResult { + validateConstraintDefinitions(constraints); + + const violations: ConstraintViolation[] = []; + const totalValue = portfolio.totalValue(prices); + const totalStroops = totalValue.toStroops(); + + // -- member-count ----------------------------------------------------------- + if (constraints.maxMemberCount !== undefined) { + if (members.length > constraints.maxMemberCount) { + violations.push({ + kind: "member-count", + actual: members.length, + limit: constraints.maxMemberCount, + }); + } + } + + // Early out when total value is zero: weight / exposure ratios are undefined. + if (totalStroops <= 0n) { + return { violations, valid: violations.length === 0 }; + } + + // -- member-weight ---------------------------------------------------------- + if (constraints.maxMemberWeight !== undefined) { + const limit = constraints.maxMemberWeight; + for (const member of members) { + const memberValue = grossMemberValue( + member, + portfolio, + proposedOrders, + prices + ); + const weight = memberValue.div(totalValue); + if (weight.compareTo(limit) > 0) { + violations.push({ + kind: "member-weight", + memberId: member.id, + actual: weight, + limit, + }); + } + } + } + + // -- aggregate-exposure per asset ------------------------------------------- + if (constraints.maxAggregateExposure !== undefined) { + const limit = constraints.maxAggregateExposure; + const exposures = aggregateExposures(portfolio, proposedOrders, prices); + + for (const [asset, exposure] of exposures) { + if (exposure.compareTo(limit) > 0) { + violations.push({ + kind: "aggregate-exposure", + asset, + actual: exposure, + limit, + }); + } + } + } + + // -- cash-buffer ------------------------------------------------------------ + if (constraints.minCashBuffer !== undefined) { + const limit = constraints.minCashBuffer; + const cashBuffer = projectedCash(portfolio, proposedOrders, prices).div( + totalValue + ); + if (cashBuffer.compareTo(limit) < 0) { + violations.push({ + kind: "cash-buffer", + actual: cashBuffer, + limit, + }); + } + } + + return { violations, valid: violations.length === 0 }; +} + +// --------------------------------------------------------------------------- +// Enforcement policies +// --------------------------------------------------------------------------- + +/** + * Enforce constraints with the "reject" policy: throw a typed error listing + * every violation when the composition is invalid. + * + * @throws {CompositionConstraintError} when any constraint is breached. + */ +export function enforceReject(result: ValidationResult): void { + if (!result.valid) { + const summary = result.violations + .map((v) => describeViolation(v)) + .join("; "); + throw new CompositionConstraintError( + "constraint-violated", + `Composition constraint violated: ${summary}`, + result.violations + ); + } +} + +/** + * Clamp result returned by `enforceClamp`. + */ +export interface ClampResult { + /** Orders after clamping. Some sizes may have been reduced to zero. */ + readonly orders: readonly Order[]; + /** The violations that triggered clamping (empty when nothing was clamped). */ + readonly clampedViolations: readonly ConstraintViolation[]; +} + +/** + * Enforce constraints with the "clamp" policy: reduce offending orders so the + * post-trade composition no longer breaches an aggregate-exposure limit, then + * return the trimmed orders together with the violations that were clamped. + * + * Only `aggregate-exposure` violations can be clamped. Member-count, + * member-weight and cash-buffer violations are rejected because trimming + * orders cannot guarantee those limits are met. + * + * @param result - The validation result from `validateComposition`. + * @param orders - The orders to clamp. + * @param prices - Current prices used to scale order notionals. + * @param totalValue - Total portfolio value used to compute the limit notional. + * @param portfolio - Current portfolio state, used to measure existing exposure. + * @throws {CompositionConstraintError} when current exposure already breaches + * the limit or a violation cannot be fixed by trimming orders. + */ +export function enforceClamp( + result: ValidationResult, + orders: readonly Order[], + prices: ReadonlyMap, + totalValue: FixedPointDecimal, + portfolio: PortfolioView +): ClampResult { + if (result.valid) { + return { orders: [...orders], clampedViolations: [] }; + } + + const totalStroops = totalValue.toStroops(); + if (totalStroops <= 0n) { + throw new CompositionConstraintError( + "constraint-violated", + "cannot clamp a composition with non-positive total value", + result.violations + ); + } + + const clampedViolations: AggregateExposureViolation[] = []; + const mutableOrders: Order[] = orders.map((order) => ({ ...order })); + + for (const violation of result.violations) { + if (violation.kind !== "aggregate-exposure") { + throw new CompositionConstraintError( + "constraint-violated", + `cannot clamp ${violation.kind} violation by trimming orders: ${describeViolation(violation)}`, + [violation] + ); + } + + const price = requirePrice(prices, violation.asset); + const targetStroops = (violation.limit.toStroops() * totalStroops) / UNIT; + const currentStroops = currentAssetExposureStroops( + portfolio, + violation.asset, + price.toStroops() + ); + + if (currentStroops >= targetStroops) { + throw new CompositionConstraintError( + "constraint-violated", + `cannot clamp ${violation.asset} exposure because current exposure already breaches the limit: ${describeViolation(violation)}`, + [violation] + ); + } + + const availableStroops = targetStroops - currentStroops; + const increasing: Array<{ + readonly index: number; + readonly currentSize: bigint; + readonly increase: bigint; + }> = []; + + let increasingStroops = 0n; + for (let index = 0; index < mutableOrders.length; index += 1) { + const order = mutableOrders[index]!; + if (order.asset !== violation.asset) continue; + + const currentSize = currentMemberSize( + portfolio, + order.memberId, + order.asset + ); + const orderSize = order.size.toStroops(); + const currentAbs = absStroops(currentSize); + const newAbs = absStroops(currentSize + orderSize); + if (newAbs <= currentAbs) continue; + + const increase = newAbs - currentAbs; + increasing.push({ index, currentSize, increase }); + increasingStroops += (increase * price.toStroops()) / UNIT; + } + + if (increasingStroops <= availableStroops) continue; + + const scaleStroops = (availableStroops * UNIT) / increasingStroops; + for (const { index, currentSize, increase } of increasing) { + const order = mutableOrders[index]!; + const clampedIncrease = (increase * scaleStroops) / UNIT; + const currentAbs = absStroops(currentSize); + const clampedAbs = currentAbs + clampedIncrease; + const clampedSize = + currentSize === 0n + ? order.size.toStroops() < 0n + ? -clampedAbs + : clampedAbs + : currentSize < 0n + ? -clampedAbs + : clampedAbs; + + mutableOrders[index] = { + ...order, + size: FixedPointDecimal.fromStroops(clampedSize - currentSize), + }; + } + + clampedViolations.push(violation); + } + + return { orders: mutableOrders, clampedViolations }; +} + +// --------------------------------------------------------------------------- +// Config type +// --------------------------------------------------------------------------- + +export type ConstraintEnforcementMode = "reject" | "clamp"; + +export interface CompositionConstraintConfig { + readonly constraints: CompositionConstraints; + /** Defaults to "reject". */ + readonly mode?: ConstraintEnforcementMode | undefined; +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** + * Throws when a constraint definition itself is invalid. Exported so callers + * that validate a composition before any positions exist (for example at + * strategy init) can still reject malformed limits. + */ +export function validateConstraintDefinitions( + constraints: CompositionConstraints +): void { + const one = FixedPointDecimal.fromStroops(UNIT); + + if (constraints.maxMemberWeight !== undefined) { + const v = constraints.maxMemberWeight; + if (v.toStroops() <= 0n || v.compareTo(one) > 0) { + throw new CompositionConstraintError( + "invalid-max-member-weight", + `maxMemberWeight must be in (0, 1], received ${v.toString()}` + ); + } + } + + if (constraints.maxAggregateExposure !== undefined) { + const v = constraints.maxAggregateExposure; + if (v.toStroops() <= 0n || v.compareTo(one) > 0) { + throw new CompositionConstraintError( + "invalid-max-aggregate-exposure", + `maxAggregateExposure must be in (0, 1], received ${v.toString()}` + ); + } + } + + if (constraints.maxMemberCount !== undefined) { + if ( + !Number.isInteger(constraints.maxMemberCount) || + constraints.maxMemberCount < 1 + ) { + throw new CompositionConstraintError( + "invalid-max-member-count", + `maxMemberCount must be a positive integer, received ${constraints.maxMemberCount}` + ); + } + } + + if (constraints.minCashBuffer !== undefined) { + const v = constraints.minCashBuffer; + if (v.toStroops() < 0n || v.compareTo(one) >= 0) { + throw new CompositionConstraintError( + "invalid-min-cash-buffer", + `minCashBuffer must be in [0, 1), received ${v.toString()}` + ); + } + } +} + +function absStroops(value: bigint): bigint { + return value < 0n ? -value : value; +} + +function notionalStroops(size: bigint, price: bigint): bigint { + return (absStroops(size) * price) / UNIT; +} + +function requirePrice( + prices: ReadonlyMap, + asset: AssetSymbol +): FixedPointDecimal { + const price = prices.get(asset); + if (price === undefined) throw new UnknownAssetError(asset); + if (price.toStroops() <= 0n) { + throw new RangeError(`price for ${asset} must be positive`); + } + return price; +} + +function ownsMember(ownerId: string, memberId: string | undefined): boolean { + return ( + memberId === ownerId || + (memberId !== undefined && memberId.startsWith(`${ownerId}/`)) + ); +} + +function ownsNestedMember( + ownerId: string, + memberId: string | undefined +): boolean { + return ( + memberId !== undefined && + memberId !== ownerId && + memberId.startsWith(`${ownerId}/`) + ); +} + +function addSize( + sizes: Map, + asset: AssetSymbol, + size: bigint +): void { + sizes.set(asset, (sizes.get(asset) ?? 0n) + size); +} + +function grossMemberValue( + member: MemberView, + portfolio: PortfolioView, + proposedOrders: readonly Order[], + prices: ReadonlyMap +): FixedPointDecimal { + const sizes = new Map(); + for (const position of member.portfolio.positions()) { + addSize(sizes, position.asset, position.size.toStroops()); + } + for (const position of portfolio.positions()) { + if (!ownsNestedMember(member.id, position.memberId)) continue; + addSize(sizes, position.asset, position.size.toStroops()); + } + for (const order of proposedOrders) { + if (!ownsMember(member.id, order.memberId)) continue; + addSize(sizes, order.asset, order.size.toStroops()); + } + + let total = 0n; + for (const [asset, size] of sizes) { + if (size === 0n) continue; + total += notionalStroops(size, requirePrice(prices, asset).toStroops()); + } + return FixedPointDecimal.fromStroops(total); +} + +function aggregateExposures( + portfolio: PortfolioView, + proposedOrders: readonly Order[], + prices: ReadonlyMap +): ReadonlyMap { + const sizes = new Map(); + for (const position of portfolio.positions()) { + sizes.set( + position.asset, + (sizes.get(position.asset) ?? 0n) + position.size.toStroops() + ); + } + for (const order of proposedOrders) { + sizes.set( + order.asset, + (sizes.get(order.asset) ?? 0n) + order.size.toStroops() + ); + } + + const totalStroops = portfolio.totalValue(prices).toStroops(); + const exposures = new Map(); + for (const [asset, size] of sizes) { + if (size === 0n) continue; + const price = requirePrice(prices, asset); + const notional = notionalStroops(size, price.toStroops()); + exposures.set( + asset, + FixedPointDecimal.fromStroops((notional * UNIT) / totalStroops) + ); + } + return exposures; +} + +function projectedCash( + portfolio: PortfolioView, + proposedOrders: readonly Order[], + prices: ReadonlyMap +): FixedPointDecimal { + let cash = portfolio.cash; + for (const order of proposedOrders) { + const price = requirePrice(prices, order.asset); + cash = cash.sub(order.size.mul(price)); + } + return cash; +} + +function currentAssetExposureStroops( + portfolio: PortfolioView, + asset: AssetSymbol, + price: bigint +): bigint { + let total = 0n; + for (const position of portfolio.positions()) { + if (position.asset !== asset) continue; + total += notionalStroops(position.size.toStroops(), price); + } + return total; +} + +function currentMemberSize( + portfolio: PortfolioView, + memberId: string | undefined, + asset: AssetSymbol +): bigint { + const position = portfolio + .positions() + .find( + (candidate) => + candidate.asset === asset && candidate.memberId === memberId + ); + return position?.size.toStroops() ?? 0n; +} + +function describeViolation(violation: ConstraintViolation): string { + switch (violation.kind) { + case "member-weight": + return `member "${violation.memberId}" weight ${violation.actual.toString()} exceeds limit ${violation.limit.toString()}`; + case "aggregate-exposure": + return `asset "${violation.asset}" aggregate exposure ${violation.actual.toString()} exceeds limit ${violation.limit.toString()}`; + case "member-count": + return `member count ${violation.actual} exceeds limit ${violation.limit}`; + case "cash-buffer": + return `cash buffer ${violation.actual.toString()} is below minimum ${violation.limit.toString()}`; + } +} diff --git a/packages/strategies/src/composition/index.test.ts b/packages/strategies/src/composition/index.test.ts index f47ddbfb..80f8d174 100644 --- a/packages/strategies/src/composition/index.test.ts +++ b/packages/strategies/src/composition/index.test.ts @@ -4,10 +4,11 @@ import { SimulationClock, ONE_HOUR_MS } from "../clock"; import { BacktestPriceFeed } from "../feeds"; import { ZERO_COSTS } from "../costs"; import type { AccrualConfig } from "../backtest"; -import type { Strategy } from "../strategy"; +import type { PortfolioView, PositionView, Strategy } from "../strategy"; import { BuyAndHoldStrategy } from "../strategy"; import { FixedPointDecimal } from "../types"; import type { AssetSymbol } from "../types"; +import { CompositionConstraintError } from "./constraint-engine"; import { runBacktest } from "../backtest"; import type { ExecutionConfig } from "../backtest"; import { @@ -21,6 +22,43 @@ const USDC: AssetSymbol = "USDC"; const START = 1_700_000_000_000; const amount = (value: string) => FixedPointDecimal.fromString(value); +const PRICES: ReadonlyMap = new Map([ + [USDC, amount("1")], + ["EURC", amount("1")], +]); + +function staticPortfolio( + cash: string, + positions: ReadonlyArray<{ + asset: AssetSymbol; + size: string; + memberId?: string; + }> +): PortfolioView { + const views: PositionView[] = positions.map((position) => ({ + asset: position.asset, + size: amount(position.size), + basis: amount("0"), + ...(position.memberId === undefined ? {} : { memberId: position.memberId }), + })); + return { + cash: amount(cash), + getPosition: (asset) => views.find((view) => view.asset === asset), + positions: () => views, + totalValue: (prices) => { + let total = amount(cash); + for (const view of views) { + const price = prices.get(view.asset); + if (price === undefined) { + throw new RangeError(`no price for ${view.asset}`); + } + total = total.add(view.size.mul(price)); + } + return total; + }, + }; +} + function backtest(strategy: Strategy, funding = false) { return runBacktest({ clock: new SimulationClock({ @@ -416,4 +454,210 @@ describe("ComposedStrategy", () => { expect(result.snapshots[0]?.positions[0]?.memberId).toBe("outer/inner"); expect(result.finalValue.toString()).toBe("1000"); }); + + it("rejects a member count limit at init", () => { + const noop: Strategy = { + name: "noop", + init: () => undefined, + step: () => undefined, + rebalance: () => [], + close: () => [], + }; + const strategy = new ComposedStrategy( + [ + { + id: "first", + strategy: noop, + allocation: amount("0.5"), + config: null, + }, + { + id: "second", + strategy: noop, + allocation: amount("0.5"), + config: null, + }, + ], + { constraints: { maxMemberCount: 1 } } + ); + + expect(() => + strategy.init({ startingCapital: amount("1000"), config: undefined }) + ).toThrow(CompositionConstraintError); + }); + + it("rejects a rebalance that breaches aggregate exposure", () => { + const buy = (): Strategy => ({ + name: "buy", + init: () => undefined, + step: () => undefined, + rebalance: () => [{ asset: USDC, size: amount("300") }], + close: () => [], + }); + const strategy = new ComposedStrategy( + [ + { + id: "first", + strategy: buy(), + allocation: amount("0.5"), + config: null, + }, + { + id: "second", + strategy: buy(), + allocation: amount("0.5"), + config: null, + }, + ], + { constraints: { maxAggregateExposure: amount("0.5") } } + ); + strategy.init({ + startingCapital: amount("1000"), + config: undefined, + }); + + expect(() => + strategy.rebalance({ + market: { timestamp: 1, prices: PRICES }, + portfolio: staticPortfolio("1000", []), + }) + ).toThrow(CompositionConstraintError); + }); + + it("clamps a rebalance that would breach aggregate exposure", () => { + const buy = (): Strategy => ({ + name: "buy", + init: () => undefined, + step: () => undefined, + rebalance: () => [{ asset: USDC, size: amount("300") }], + close: () => [], + }); + const strategy = new ComposedStrategy( + [ + { + id: "first", + strategy: buy(), + allocation: amount("0.5"), + config: null, + }, + { + id: "second", + strategy: buy(), + allocation: amount("0.5"), + config: null, + }, + ], + { + constraints: { maxAggregateExposure: amount("0.5") }, + mode: "clamp", + } + ); + strategy.init({ + startingCapital: amount("1000"), + config: undefined, + }); + + const orders = strategy.rebalance({ + market: { timestamp: 1, prices: PRICES }, + portfolio: staticPortfolio("1000", []), + }); + + const totalSize = orders.reduce( + (total, order) => total + order.size.toStroops(), + 0n + ); + expect(totalSize).toBeLessThanOrEqual(amount("500").toStroops()); + expect(totalSize).toBeGreaterThan(0n); + expect(orders.map((order) => order.memberId)).toEqual(["first", "second"]); + }); + + it("measures member weight from the member's own positions", () => { + const noop: Strategy = { + name: "noop", + init: () => undefined, + step: () => undefined, + rebalance: () => [], + close: () => [], + }; + const strategy = new ComposedStrategy( + [ + { + id: "heavy", + strategy: noop, + allocation: amount("0.5"), + config: null, + }, + { + id: "light", + strategy: noop, + allocation: amount("0.5"), + config: null, + }, + ], + { constraints: { maxMemberWeight: amount("0.7") } } + ); + strategy.init({ + startingCapital: amount("1000"), + config: undefined, + }); + + expect(() => + strategy.rebalance({ + market: { timestamp: 1, prices: PRICES }, + portfolio: staticPortfolio("0", [ + { asset: USDC, size: "800", memberId: "heavy" }, + { asset: USDC, size: "200", memberId: "light" }, + ]), + }) + ).toThrow(CompositionConstraintError); + }); + + it("counts nested member positions toward member weight", () => { + const noop: Strategy = { + name: "noop", + init: () => undefined, + step: () => undefined, + rebalance: () => [], + close: () => [], + }; + const inner = new ComposedStrategy([ + { + id: "inner", + strategy: noop, + allocation: amount("1"), + config: null, + }, + ]); + const strategy = new ComposedStrategy( + [ + { + id: "outer", + strategy: inner, + allocation: amount("0.5"), + config: undefined, + }, + { + id: "other", + strategy: noop, + allocation: amount("0.5"), + config: null, + }, + ], + { constraints: { maxMemberWeight: amount("0.7") } } + ); + strategy.init({ + startingCapital: amount("1000"), + config: undefined, + }); + + expect(() => + strategy.rebalance({ + market: { timestamp: 1, prices: PRICES }, + portfolio: staticPortfolio("0", [ + { asset: USDC, size: "800", memberId: "outer/inner" }, + { asset: USDC, size: "200", memberId: "other" }, + ]), + }) + ).toThrow(CompositionConstraintError); + }); }); diff --git a/packages/strategies/src/composition/index.ts b/packages/strategies/src/composition/index.ts index 33f12609..700b69a0 100644 --- a/packages/strategies/src/composition/index.ts +++ b/packages/strategies/src/composition/index.ts @@ -14,6 +14,13 @@ import type { StrategyInitParams, } from "../strategy"; import { allocateCapital } from "./capital-allocation"; +import { + enforceClamp, + enforceReject, + validateComposition, + type CompositionConstraintConfig, + type MemberView, +} from "./constraint-engine"; /** * A member of a composed strategy. `allocation` is the fraction of the @@ -44,6 +51,10 @@ interface InitializedMember { * Returned orders are tagged with the member id so same-asset positions stay * distinct in the runner's portfolio. Member ids cannot contain `/`, which * separates IDs in nested compositions. + * + * An optional `constraintConfig` applies composition-wide limits at init and + * on every rebalance. The default enforcement mode is `reject`; `clamp` + * trims aggregate-exposure orders that would breach an asset limit. */ export class ComposedStrategy implements Strategy { readonly name = "composed-strategy"; @@ -52,8 +63,12 @@ export class ComposedStrategy implements Strategy { readonly allocation: FixedPointDecimal; }[]; readonly #members: readonly InitializedMember[]; + readonly #constraintConfig: CompositionConstraintConfig | undefined; - constructor(members: readonly ComposedStrategyMember[]) { + constructor( + members: readonly ComposedStrategyMember[], + constraintConfig?: CompositionConstraintConfig + ) { if (members.length === 0) { throw new RangeError("a composed strategy requires at least one member"); } @@ -98,9 +113,31 @@ export class ComposedStrategy implements Strategy { id, allocation, })); + this.#constraintConfig = constraintConfig; } init(_params: StrategyInitParams): void { + if (this.#constraintConfig !== undefined) { + const emptyPortfolio: PortfolioView = { + cash: _params.startingCapital, + getPosition: () => undefined, + positions: () => [], + totalValue: () => _params.startingCapital, + }; + const memberViews: MemberView[] = this.#members.map((member) => ({ + id: member.id, + portfolio: member.portfolio(emptyPortfolio), + })); + enforceReject( + validateComposition( + this.#constraintConfig.constraints, + memberViews, + emptyPortfolio, + new Map() + ) + ); + } + const capital = _params.startingCapital.toStroops(); const sign = capital < 0n ? -1n : 1n; const allocation = allocateCapital( @@ -131,12 +168,40 @@ export class ComposedStrategy implements Strategy { } rebalance(context: StrategyContext): readonly Order[] { - return this.#members.flatMap((member) => + const orders = this.#members.flatMap((member) => this.#tagOrders( this.#memberNamespace(member.id, context.portfolio), member.strategy.rebalance(this.#memberContext(member, context)) ) ); + + const config = this.#constraintConfig; + if (config === undefined) return orders; + + const memberViews: MemberView[] = this.#members.map((member) => ({ + id: member.id, + portfolio: member.portfolio(context.portfolio), + })); + const validation = validateComposition( + config.constraints, + memberViews, + context.portfolio, + context.market.prices, + orders + ); + + if ((config.mode ?? "reject") === "clamp") { + return enforceClamp( + validation, + orders, + context.market.prices, + context.portfolio.totalValue(context.market.prices), + context.portfolio + ).orders; + } + + enforceReject(validation); + return orders; } close(context: StrategyContext): readonly Order[] { diff --git a/packages/strategies/src/index.ts b/packages/strategies/src/index.ts index 5e22656b..6e4d6349 100644 --- a/packages/strategies/src/index.ts +++ b/packages/strategies/src/index.ts @@ -47,3 +47,4 @@ export * from "./report"; export * from "./registry"; export * from "./composition/capital-allocation"; export * from "./composition/correlation-matrix"; +export * from "./composition/constraint-engine";