From e4c71a868697bffbe0b7983d339e1b32cd280098 Mon Sep 17 00:00:00 2001 From: CodlithLab Date: Thu, 22 Oct 2026 12:59:52 +0100 Subject: [PATCH 1/4] feat(sdk): add composition constraint engine (#1199) Add a pure constraint validator that enforces allocation and aggregate-exposure limits on a composition at init and after every rebalance. - Add CompositionConstraints type covering maxMemberWeight, maxAggregateExposure, maxMemberCount, and minCashBuffer - Add validateComposition() returning typed violations; computes aggregate exposure across members rather than per member - Add enforceReject() and enforceClamp() for the two enforcement policies; mode is explicit in config, default is reject - Export all new types and functions from the package index - Add tests covering each constraint and both enforcement modes Closes #1199 --- .../src/composition/constraint-engine.test.ts | 555 ++++++++++++++++++ .../src/composition/constraint-engine.ts | 461 +++++++++++++++ packages/strategies/src/index.ts | 1 + 3 files changed, 1017 insertions(+) create mode 100644 packages/strategies/src/composition/constraint-engine.test.ts create mode 100644 packages/strategies/src/composition/constraint-engine.ts 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..54b55676 --- /dev/null +++ b/packages/strategies/src/composition/constraint-engine.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect } from "vitest"; +import { + validateComposition, + enforceReject, + enforceClamp, + CompositionConstraintError, + isCompositionConstraintError, + type CompositionConstraints, + type MemberView, +} from "./constraint-engine"; +import { FixedPointDecimal } 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"), + 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" }]); + // total = 1000, cash = 50, buffer = 0.05 < 0.1 + const result = validateComposition( + { minCashBuffer: D("0.1") }, + members, + portfolio, + PRICES + ); + 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"); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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("returns orders unchanged when the composition is valid", () => { + const orders = [{ asset: USDC, size: D("100") }]; + const result = enforceClamp( + { valid: true, violations: [] }, + orders, + PRICES, + D("1000"), + { maxAggregateExposure: D("0.6") } + ); + expect(result.orders).toHaveLength(1); + expect(result.orders[0]?.size.toString()).toBe("100"); + expect(result.clampedViolations).toHaveLength(0); + }); + + it("scales down orders to fit within the aggregate-exposure limit", () => { + // total value = 1000, limit = 50%, max notional = 500 + // proposed orders sum to 600 notional → should be scaled to 500 + const orders = [ + { asset: USDC, size: D("400") }, + { asset: USDC, size: D("200") }, + ]; + const members = [ + makeMember("a", "0", [{ asset: USDC, size: "400" }]), + makeMember("b", "0", [{ asset: USDC, size: "200" }]), + ]; + const portfolio = makePortfolio("400", [{ asset: USDC, size: "600" }]); + const validationResult = validateComposition( + { maxAggregateExposure: D("0.5") }, + members, + portfolio, + PRICES + ); + expect(validationResult.valid).toBe(false); + + const clampResult = enforceClamp( + validationResult, + orders, + PRICES, + D("1000"), + { maxAggregateExposure: D("0.5") } + ); + + // Total clamped size should be ≤ 500 + const totalSize = clampResult.orders.reduce( + (sum, o) => sum + o.size.toStroops(), + 0n + ); + expect(totalSize).toBeLessThanOrEqual(D("500").toStroops()); + expect(clampResult.clampedViolations.length).toBeGreaterThan(0); + }); + + it("reports non-order violations in clampedViolations without altering orders", () => { + // member-count violation cannot be fixed by clamping orders + 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") }]; + const clampResult = enforceClamp( + result, + orders, + PRICES, + D("300"), + { maxMemberCount: 2 } + ); + + // Orders unchanged (no aggregate-exposure violation to clamp) + expect(clampResult.orders[0]?.size.toString()).toBe("50"); + expect( + clampResult.clampedViolations.some((v) => v.kind === "member-count") + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 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("skips assets with no price when checking aggregate exposure", () => { + 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" }]); + // Should not throw and EURC should be skipped + const result = validateComposition( + { maxAggregateExposure: D("0.1") }, + members, + portfolio, + noPrices + ); + // EURC skipped (no price), no violation + expect(result.violations.filter((v) => v.kind === "aggregate-exposure")).toHaveLength(0); + }); +}); diff --git a/packages/strategies/src/composition/constraint-engine.ts b/packages/strategies/src/composition/constraint-engine.ts new file mode 100644 index 00000000..17f21836 --- /dev/null +++ b/packages/strategies/src/composition/constraint-engine.ts @@ -0,0 +1,461 @@ +/** + * 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. + * + * Constraints are validated at init and after every rebalance. Members never + * mutate one another's orders; the composition applies the policy when merging + * member orders. + */ +import { FixedPointDecimal } from "../types"; +import type { AssetSymbol } from "../types"; +import type { Order, PortfolioView } from "../strategy"; + +// --------------------------------------------------------------------------- +// 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 that any single member may + * represent (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 aggregate per-asset exposure across all 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. + * @throws {CompositionConstraintError} when a constraint definition is itself + * invalid (e.g. maxMemberWeight > 1). + */ +export function validateComposition( + constraints: CompositionConstraints, + members: readonly MemberView[], + portfolio: PortfolioView, + prices: ReadonlyMap +): 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 }; + } + + const one = FixedPointDecimal.fromStroops(10_000_000n); // 1.0 + + // -- member-weight ---------------------------------------------------------- + if (constraints.maxMemberWeight !== undefined) { + const limit = constraints.maxMemberWeight; + for (const member of members) { + const memberValue = member.portfolio.totalValue(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; + + // Collect all asset symbols across members. + const assetSet = new Set(); + for (const member of members) { + for (const position of member.portfolio.positions()) { + assetSet.add(position.asset); + } + } + + for (const asset of assetSet) { + const price = prices.get(asset); + if (price === undefined || price.toStroops() <= 0n) continue; + + // Sum the absolute notional across every member that holds this asset. + let aggregateNotionalStroops = 0n; + for (const member of members) { + const position = member.portfolio.getPosition(asset); + if (position === undefined) continue; + const sizeStroops = position.size.toStroops(); + const abs = sizeStroops < 0n ? -sizeStroops : sizeStroops; + // notional = |size| * price (both in stroops, result in stroops²/stroop = stroops) + const notionalStroops = + (abs * price.toStroops()) / 10_000_000n; + aggregateNotionalStroops += notionalStroops; + } + + const aggregateExposure = FixedPointDecimal.fromStroops( + (aggregateNotionalStroops * 10_000_000n) / totalStroops + ); + + if (aggregateExposure.compareTo(limit) > 0) { + violations.push({ + kind: "aggregate-exposure", + asset, + actual: aggregateExposure, + limit, + }); + } + } + } + + // -- cash-buffer ------------------------------------------------------------ + if (constraints.minCashBuffer !== undefined) { + const limit = constraints.minCashBuffer; + const cashBuffer = portfolio.cash.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 + * they no longer breach any aggregate-exposure limit, then return the trimmed + * orders together with the violations that triggered clamping. + * + * Only `aggregate-exposure` violations are clamped directly on orders; the + * other violation kinds (member-weight, member-count, cash-buffer) are + * reported but do not alter orders because they cannot be corrected by trimming + * a single order in isolation. + * + * @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. + */ +export function enforceClamp( + result: ValidationResult, + orders: readonly Order[], + prices: ReadonlyMap, + totalValue: FixedPointDecimal, + constraints: CompositionConstraints +): ClampResult { + if (result.valid) { + return { orders: [...orders], clampedViolations: [] }; + } + + const clampedViolations: ConstraintViolation[] = [...result.violations]; + + // Build a mutable copy of the orders indexed by asset. + const mutableOrders: Order[] = orders.map((o) => ({ ...o })); + + // For each aggregate-exposure violation, scale down orders for that asset. + const exposureViolations = result.violations.filter( + (v): v is AggregateExposureViolation => v.kind === "aggregate-exposure" + ); + + for (const violation of exposureViolations) { + const { asset, limit } = violation; + const price = prices.get(asset); + if (price === undefined || price.toStroops() <= 0n) continue; + + const totalValueStroops = totalValue.toStroops(); + if (totalValueStroops <= 0n) continue; + + // Maximum allowed absolute notional for this asset. + const maxNotionalStroops = + (limit.toStroops() * totalValueStroops) / 10_000_000n; + + // Current total absolute notional from the proposed orders. + let currentNotionalStroops = 0n; + for (const order of mutableOrders) { + if (order.asset !== asset) continue; + const sizeStroops = order.size.toStroops(); + const abs = sizeStroops < 0n ? -sizeStroops : sizeStroops; + currentNotionalStroops += (abs * price.toStroops()) / 10_000_000n; + } + + if (currentNotionalStroops === 0n) continue; + + // Scale factor to apply to every order for this asset. + // scaleStroops = maxNotional / currentNotional (as a fixed-point fraction). + const scaleStroops = + (maxNotionalStroops * 10_000_000n) / currentNotionalStroops; + + for (let idx = 0; idx < mutableOrders.length; idx++) { + const order = mutableOrders[idx]!; + if (order.asset !== asset) continue; + const sizeStroops = order.size.toStroops(); + const clampedStroops = (sizeStroops * scaleStroops) / 10_000_000n; + mutableOrders[idx] = { + ...order, + size: FixedPointDecimal.fromStroops(clampedStroops), + }; + } + } + + return { orders: mutableOrders, clampedViolations }; +} + +// --------------------------------------------------------------------------- +// Config type +// --------------------------------------------------------------------------- + +export type ConstraintEnforcementMode = "reject" | "clamp"; + +export interface CompositionConstraintConfig { + readonly constraints: CompositionConstraints; + /** Default: "reject". */ + readonly mode: ConstraintEnforcementMode; +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +function validateConstraintDefinitions( + constraints: CompositionConstraints +): void { + const one = FixedPointDecimal.fromStroops(10_000_000n); + const zero = FixedPointDecimal.fromStroops(0n); + + 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 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/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"; From 4cca52b176005c80dca3e6b491053e3e9ad51ffa Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 25 Oct 2026 21:19:44 +0100 Subject: [PATCH 2/4] fix(strategies): enforce composition constraints after trade --- apps/docs/strategies/composition.md | 37 +- .../src/composition/constraint-engine.test.ts | 122 +++--- .../src/composition/constraint-engine.ts | 387 +++++++++++++----- .../strategies/src/composition/index.test.ts | 246 ++++++++++- packages/strategies/src/composition/index.ts | 69 +++- 5 files changed, 678 insertions(+), 183 deletions(-) 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 index 54b55676..bdd4e265 100644 --- a/packages/strategies/src/composition/constraint-engine.test.ts +++ b/packages/strategies/src/composition/constraint-engine.test.ts @@ -8,7 +8,7 @@ import { type CompositionConstraints, type MemberView, } from "./constraint-engine"; -import { FixedPointDecimal } from "../types"; +import { FixedPointDecimal, UnknownAssetError } from "../types"; import type { AssetSymbol } from "../types"; import type { PortfolioView, PositionView } from "../strategy"; @@ -137,9 +137,7 @@ describe("validateComposition – member-weight", () => { makeMember("a", "0", [{ asset: USDC, size: "500" }]), makeMember("b", "0", [{ asset: USDC, size: "500" }]), ]; - const portfolio = makePortfolio("0", [ - { asset: USDC, size: "1000" }, - ]); + const portfolio = makePortfolio("0", [{ asset: USDC, size: "1000" }]); const result = validateComposition( { maxMemberWeight: D("0.6") }, members, @@ -181,9 +179,7 @@ describe("validateComposition – aggregate-exposure", () => { makeMember("a", "0", [{ asset: USDC, size: "300" }]), makeMember("b", "0", [{ asset: USDC, size: "200" }]), ]; - const portfolio = makePortfolio("500", [ - { asset: USDC, size: "500" }, - ]); + 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( @@ -285,9 +281,7 @@ describe("validateComposition – cash-buffer", () => { describe("enforceReject", () => { it("does nothing when the composition is valid", () => { - expect(() => - enforceReject({ valid: true, violations: [] }) - ).not.toThrow(); + expect(() => enforceReject({ valid: true, violations: [] })).not.toThrow(); }); it("throws a CompositionConstraintError naming the violated constraint", () => { @@ -347,35 +341,31 @@ describe("enforceReject", () => { describe("enforceClamp", () => { 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"), - { maxAggregateExposure: D("0.6") } + portfolio ); expect(result.orders).toHaveLength(1); expect(result.orders[0]?.size.toString()).toBe("100"); expect(result.clampedViolations).toHaveLength(0); }); - it("scales down orders to fit within the aggregate-exposure limit", () => { - // total value = 1000, limit = 50%, max notional = 500 - // proposed orders sum to 600 notional → should be scaled to 500 - const orders = [ - { asset: USDC, size: D("400") }, - { asset: USDC, size: D("200") }, - ]; - const members = [ - makeMember("a", "0", [{ asset: USDC, size: "400" }]), - makeMember("b", "0", [{ asset: USDC, size: "200" }]), - ]; - const portfolio = makePortfolio("400", [{ asset: USDC, size: "600" }]); + 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.5") }, + { maxAggregateExposure: D("0.6") }, members, portfolio, - PRICES + PRICES, + orders ); expect(validationResult.valid).toBe(false); @@ -384,20 +374,17 @@ describe("enforceClamp", () => { orders, PRICES, D("1000"), - { maxAggregateExposure: D("0.5") } + portfolio ); - // Total clamped size should be ≤ 500 - const totalSize = clampResult.orders.reduce( - (sum, o) => sum + o.size.toStroops(), - 0n - ); - expect(totalSize).toBeLessThanOrEqual(D("500").toStroops()); - expect(clampResult.clampedViolations.length).toBeGreaterThan(0); + 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("reports non-order violations in clampedViolations without altering orders", () => { - // member-count violation cannot be fixed by clamping orders + it("throws when a non-order violation cannot be fixed by clamping", () => { const members = [ makeMember("a", "100", []), makeMember("b", "100", []), @@ -412,19 +399,26 @@ describe("enforceClamp", () => { ); const orders = [{ asset: USDC, size: D("50") }]; - const clampResult = enforceClamp( - result, - orders, + 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, - D("300"), - { maxMemberCount: 2 } + orders ); - // Orders unchanged (no aggregate-exposure violation to clamp) - expect(clampResult.orders[0]?.size.toString()).toBe("50"); - expect( - clampResult.clampedViolations.some((v) => v.kind === "member-count") - ).toBe(true); + expect(() => + enforceClamp(result, orders, PRICES, D("1000"), portfolio) + ).toThrow(/current exposure already breaches/); }); }); @@ -471,12 +465,7 @@ describe("invalid constraint definitions", () => { it("throws when maxMemberCount is less than 1", () => { expect(() => - validateComposition( - { maxMemberCount: 0 }, - members, - portfolio, - PRICES - ) + validateComposition({ maxMemberCount: 0 }, members, portfolio, PRICES) ).toThrow(CompositionConstraintError); }); @@ -493,12 +482,7 @@ describe("invalid constraint definitions", () => { it("throws when minCashBuffer equals 1", () => { expect(() => - validateComposition( - { minCashBuffer: D("1") }, - members, - portfolio, - PRICES - ) + validateComposition({ minCashBuffer: D("1") }, members, portfolio, PRICES) ).toThrow(CompositionConstraintError); }); @@ -533,23 +517,21 @@ describe("edge cases", () => { expect(result.violations).toHaveLength(0); }); - it("skips assets with no price when checking aggregate exposure", () => { + 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 members = [makeMember("a", "0", [{ asset: EURC, size: "1000" }])]; const portfolio = makePortfolio("1000", [{ asset: EURC, size: "1000" }]); - // Should not throw and EURC should be skipped - const result = validateComposition( - { maxAggregateExposure: D("0.1") }, - members, - portfolio, - noPrices - ); - // EURC skipped (no price), no violation - expect(result.violations.filter((v) => v.kind === "aggregate-exposure")).toHaveLength(0); + + 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 index 17f21836..b49a565c 100644 --- a/packages/strategies/src/composition/constraint-engine.ts +++ b/packages/strategies/src/composition/constraint-engine.ts @@ -12,14 +12,16 @@ * - "clamp": reduce offending orders before they are applied and return a * `ClampResult` describing what was trimmed. * - * Constraints are validated at init and after every rebalance. Members never - * mutate one another's orders; the composition applies the policy when merging - * member orders. + * 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 } from "../types"; +import { FixedPointDecimal, UnknownAssetError } from "../types"; import type { AssetSymbol } from "../types"; import type { Order, PortfolioView } from "../strategy"; +const UNIT = 10_000_000n; + // --------------------------------------------------------------------------- // Constraints // --------------------------------------------------------------------------- @@ -30,8 +32,8 @@ import type { Order, PortfolioView } from "../strategy"; */ export interface CompositionConstraints { /** - * Maximum fraction of total portfolio value that any single member may - * represent (0 < maxMemberWeight ≤ 1). Checked after rebalance. + * Maximum fraction of total portfolio value held as one member's gross + * position exposure (0 < maxMemberWeight ≤ 1). Checked after rebalance. */ readonly maxMemberWeight?: FixedPointDecimal | undefined; @@ -59,10 +61,7 @@ export interface CompositionConstraints { // --------------------------------------------------------------------------- export type ConstraintViolationKind = - | "member-weight" - | "aggregate-exposure" - | "member-count" - | "cash-buffer"; + "member-weight" | "aggregate-exposure" | "member-count" | "cash-buffer"; export interface MemberWeightViolation { readonly kind: "member-weight"; @@ -155,22 +154,25 @@ export interface ValidationResult { // --------------------------------------------------------------------------- /** - * Pure validator. Computes aggregate per-asset exposure across all members - * and checks every enabled constraint. Returns every violation found; the - * caller chooses the enforcement policy (reject or clamp). + * 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 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 + prices: ReadonlyMap, + proposedOrders: readonly Order[] = [] ): ValidationResult { validateConstraintDefinitions(constraints); @@ -194,13 +196,16 @@ export function validateComposition( return { violations, valid: violations.length === 0 }; } - const one = FixedPointDecimal.fromStroops(10_000_000n); // 1.0 - // -- member-weight ---------------------------------------------------------- if (constraints.maxMemberWeight !== undefined) { const limit = constraints.maxMemberWeight; for (const member of members) { - const memberValue = member.portfolio.totalValue(prices); + const memberValue = grossMemberValue( + member, + portfolio, + proposedOrders, + prices + ); const weight = memberValue.div(totalValue); if (weight.compareTo(limit) > 0) { violations.push({ @@ -216,41 +221,14 @@ export function validateComposition( // -- aggregate-exposure per asset ------------------------------------------- if (constraints.maxAggregateExposure !== undefined) { const limit = constraints.maxAggregateExposure; + const exposures = aggregateExposures(portfolio, proposedOrders, prices); - // Collect all asset symbols across members. - const assetSet = new Set(); - for (const member of members) { - for (const position of member.portfolio.positions()) { - assetSet.add(position.asset); - } - } - - for (const asset of assetSet) { - const price = prices.get(asset); - if (price === undefined || price.toStroops() <= 0n) continue; - - // Sum the absolute notional across every member that holds this asset. - let aggregateNotionalStroops = 0n; - for (const member of members) { - const position = member.portfolio.getPosition(asset); - if (position === undefined) continue; - const sizeStroops = position.size.toStroops(); - const abs = sizeStroops < 0n ? -sizeStroops : sizeStroops; - // notional = |size| * price (both in stroops, result in stroops²/stroop = stroops) - const notionalStroops = - (abs * price.toStroops()) / 10_000_000n; - aggregateNotionalStroops += notionalStroops; - } - - const aggregateExposure = FixedPointDecimal.fromStroops( - (aggregateNotionalStroops * 10_000_000n) / totalStroops - ); - - if (aggregateExposure.compareTo(limit) > 0) { + for (const [asset, exposure] of exposures) { + if (exposure.compareTo(limit) > 0) { violations.push({ kind: "aggregate-exposure", asset, - actual: aggregateExposure, + actual: exposure, limit, }); } @@ -260,7 +238,9 @@ export function validateComposition( // -- cash-buffer ------------------------------------------------------------ if (constraints.minCashBuffer !== undefined) { const limit = constraints.minCashBuffer; - const cashBuffer = portfolio.cash.div(totalValue); + const cashBuffer = projectedCash(portfolio, proposedOrders, prices).div( + totalValue + ); if (cashBuffer.compareTo(limit) < 0) { violations.push({ kind: "cash-buffer", @@ -307,79 +287,121 @@ export interface ClampResult { } /** - * Enforce constraints with the "clamp" policy: reduce offending orders so - * they no longer breach any aggregate-exposure limit, then return the trimmed - * orders together with the violations that triggered clamping. + * 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 are clamped directly on orders; the - * other violation kinds (member-weight, member-count, cash-buffer) are - * reported but do not alter orders because they cannot be corrected by trimming - * a single order in isolation. + * 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, - constraints: CompositionConstraints + portfolio: PortfolioView ): ClampResult { if (result.valid) { return { orders: [...orders], clampedViolations: [] }; } - const clampedViolations: ConstraintViolation[] = [...result.violations]; - - // Build a mutable copy of the orders indexed by asset. - const mutableOrders: Order[] = orders.map((o) => ({ ...o })); + const totalStroops = totalValue.toStroops(); + if (totalStroops <= 0n) { + throw new CompositionConstraintError( + "constraint-violated", + "cannot clamp a composition with non-positive total value", + result.violations + ); + } - // For each aggregate-exposure violation, scale down orders for that asset. - const exposureViolations = result.violations.filter( - (v): v is AggregateExposureViolation => v.kind === "aggregate-exposure" - ); + const clampedViolations: AggregateExposureViolation[] = []; + const mutableOrders: Order[] = orders.map((order) => ({ ...order })); - for (const violation of exposureViolations) { - const { asset, limit } = violation; - const price = prices.get(asset); - if (price === undefined || price.toStroops() <= 0n) continue; - - const totalValueStroops = totalValue.toStroops(); - if (totalValueStroops <= 0n) continue; - - // Maximum allowed absolute notional for this asset. - const maxNotionalStroops = - (limit.toStroops() * totalValueStroops) / 10_000_000n; - - // Current total absolute notional from the proposed orders. - let currentNotionalStroops = 0n; - for (const order of mutableOrders) { - if (order.asset !== asset) continue; - const sizeStroops = order.size.toStroops(); - const abs = sizeStroops < 0n ? -sizeStroops : sizeStroops; - currentNotionalStroops += (abs * price.toStroops()) / 10_000_000n; + 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] + ); } - if (currentNotionalStroops === 0n) continue; + const price = requirePrice(prices, violation.asset); + const targetStroops = (violation.limit.toStroops() * totalStroops) / UNIT; + const currentStroops = currentAssetExposureStroops( + portfolio, + violation.asset, + price.toStroops() + ); - // Scale factor to apply to every order for this asset. - // scaleStroops = maxNotional / currentNotional (as a fixed-point fraction). - const scaleStroops = - (maxNotionalStroops * 10_000_000n) / currentNotionalStroops; + 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; + } - for (let idx = 0; idx < mutableOrders.length; idx++) { - const order = mutableOrders[idx]!; - if (order.asset !== asset) continue; - const sizeStroops = order.size.toStroops(); - const clampedStroops = (sizeStroops * scaleStroops) / 10_000_000n; - mutableOrders[idx] = { + 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(clampedStroops), + size: FixedPointDecimal.fromStroops(clampedSize - currentSize), }; } + + clampedViolations.push(violation); } return { orders: mutableOrders, clampedViolations }; @@ -393,19 +415,23 @@ export type ConstraintEnforcementMode = "reject" | "clamp"; export interface CompositionConstraintConfig { readonly constraints: CompositionConstraints; - /** Default: "reject". */ - readonly mode: ConstraintEnforcementMode; + /** Defaults to "reject". */ + readonly mode?: ConstraintEnforcementMode | undefined; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- -function validateConstraintDefinitions( +/** + * 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(10_000_000n); - const zero = FixedPointDecimal.fromStroops(0n); + const one = FixedPointDecimal.fromStroops(UNIT); if (constraints.maxMemberWeight !== undefined) { const v = constraints.maxMemberWeight; @@ -428,7 +454,10 @@ function validateConstraintDefinitions( } if (constraints.maxMemberCount !== undefined) { - if (!Number.isInteger(constraints.maxMemberCount) || constraints.maxMemberCount < 1) { + if ( + !Number.isInteger(constraints.maxMemberCount) || + constraints.maxMemberCount < 1 + ) { throw new CompositionConstraintError( "invalid-max-member-count", `maxMemberCount must be a positive integer, received ${constraints.maxMemberCount}` @@ -447,6 +476,152 @@ function validateConstraintDefinitions( } } +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": 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[] { From 95b94e691a02905dd78c83b509f3ecafca32ffd1 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 25 Oct 2026 21:22:47 +0100 Subject: [PATCH 3/4] fix(strategies): remove unused test import --- packages/strategies/src/composition/constraint-engine.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/strategies/src/composition/constraint-engine.test.ts b/packages/strategies/src/composition/constraint-engine.test.ts index bdd4e265..0e4d6b42 100644 --- a/packages/strategies/src/composition/constraint-engine.test.ts +++ b/packages/strategies/src/composition/constraint-engine.test.ts @@ -5,7 +5,6 @@ import { enforceClamp, CompositionConstraintError, isCompositionConstraintError, - type CompositionConstraints, type MemberView, } from "./constraint-engine"; import { FixedPointDecimal, UnknownAssetError } from "../types"; From 6260aa4dd2375f36dcf7fc181e13b35bfbaa6cc1 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 25 Oct 2026 21:29:04 +0100 Subject: [PATCH 4/4] test(strategies): cover constraint enforcement edge cases --- .../src/composition/constraint-engine.test.ts | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/strategies/src/composition/constraint-engine.test.ts b/packages/strategies/src/composition/constraint-engine.test.ts index 0e4d6b42..28a1238b 100644 --- a/packages/strategies/src/composition/constraint-engine.test.ts +++ b/packages/strategies/src/composition/constraint-engine.test.ts @@ -27,7 +27,7 @@ function makePortfolio( asset: p.asset, size: D(p.size), basis: D("0"), - memberId: p.memberId, + ...(p.memberId === undefined ? {} : { memberId: p.memberId }), })); return { @@ -258,12 +258,14 @@ describe("validateComposition – cash-buffer", () => { 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" }]); - // total = 1000, cash = 50, buffer = 0.05 < 0.1 + 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 + PRICES, + orders ); expect(result.valid).toBe(false); const v = result.violations[0]; @@ -271,6 +273,41 @@ describe("validateComposition – 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); }); }); @@ -338,6 +375,24 @@ describe("enforceReject", () => { // --------------------------------------------------------------------------- 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" }]);