Skip to content

feat(strategies): add composition constraint engine - #1225

Merged
collinsezedike merged 5 commits into
drydocs:mainfrom
CodlithLab:feat/1199-sdk-composition-constraint-engine
Oct 25, 2026
Merged

collinsezedike merged 5 commits into
drydocs:mainfrom
CodlithLab:feat/1199-sdk-composition-constraint-engine

Conversation

@CodlithLab

Copy link
Copy Markdown
Contributor

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 [SDK] Add the 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 drydocs#1199
@drips-wave

drips-wave Bot commented Oct 22, 2026

Copy link
Copy Markdown

@CodlithLab Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Oct 22, 2026

Copy link
Copy Markdown

@CodlithLab is attempting to deploy a commit to the Collins' projects Team on Vercel.

A member of the Team first needs to authorize it.

@collinsezedike collinsezedike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@CodlithLab This adds a validator, but nothing calls it. ComposedStrategy.init and ComposedStrategy.rebalance are unchanged, so a composition that breaches a limit still runs. maxMemberWeight also cannot work with the real MemberPortfolioView, which returns the shared total for every member. The clamp path can increase orders and can leave the post-trade exposure over the limit, and it returns unclamped violations as clampedViolations. CompositionConstraintConfig.mode is never read, and constraint-engine.ts has noUnusedLocals and noUnusedParameters failures (one, constraints), so npx tsc --noEmit fails. The inline notes cover the fixes. The commit subject feat(sdk): add composition constraint engine (#1199) also uses the wrong scope for packages/strategies and includes an issue reference. Amend it to feat(strategies): add composition constraint engine.

* - "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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ComposedStrategy does not call this module. init and rebalance are unchanged (packages/strategies/src/composition/index.ts:94-116), so this claim is false and the issue acceptance for init and rebalance is not met. Add the integration or split the issue.

validateConstraintDefinitions(constraints);

const violations: ConstraintViolation[] = [];
const totalValue = portfolio.totalValue(prices);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

portfolio.totalValue(prices) throws UnknownAssetError for an unpriced position in the real Portfolio (portfolio.ts:224-227), so the continue at line 230 is not reachable for the real view. The test at line 536 uses a fake that treats missing prices as zero. Decide whether a missing price is an error or a violation and test the real behavior.

return { violations, valid: violations.length === 0 };
}

const one = FixedPointDecimal.fromStroops(10_000_000n); // 1.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

one is never used. tsconfig.json sets noUnusedLocals: true, so npx tsc --noEmit fails here. Remove it.

if (constraints.maxMemberWeight !== undefined) {
const limit = constraints.maxMemberWeight;
for (const member of members) {
const memberValue = member.portfolio.totalValue(prices);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

member.portfolio.totalValue(prices) is the shared portfolio total for MemberPortfolioView (composition/index.ts:183-187). Every member then measures as 100% of the portfolio, so maxMemberWeight cannot work with the real composition. The tests use a fake per-member total. Compute the member value from member.portfolio.positions() or expose a per-member value.

orders: readonly Order[],
prices: ReadonlyMap<AssetSymbol, FixedPointDecimal>,
totalValue: FixedPointDecimal,
constraints: CompositionConstraints

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

constraints is never used. tsconfig.json sets noUnusedParameters: true, so npx tsc --noEmit fails. Remove the parameter and update the callers, or use it.

Comment on lines +345 to +383
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),
};
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The clamp ignores the current portfolio and the order side. It scales every order for the asset, can increase orders when the proposed notional is below the limit (scaleStroops > 10_000_000n), and scales sells down even when they reduce exposure. The post-trade exposure can remain over the limit, which the test at line 362 does not check. Compute the post-trade exposure from the current positions plus the proposed orders, clamp only the orders that increase exposure, cap the scale at 1, and reject when the current exposure alone breaches the limit.


export type ConstraintEnforcementMode = "reject" | "clamp";

export interface CompositionConstraintConfig {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

CompositionConstraintConfig and mode are never read. There is no dispatcher and no default reject. The issue requires the mode to be explicit with reject as the default. Add a single enforcement entry point that reads this config, or remove the dead type.

expect(result.clampedViolations).toHaveLength(0);
});

it("scales down orders to fit within the aggregate-exposure limit", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The test only checks the summed order size. The portfolio already has 600 USDC at a 50% limit, and the clamped orders still leave the post-trade exposure above the limit. Assert the post-trade exposure, not just the order size.

expect(clampResult.clampedViolations.length).toBeGreaterThan(0);
});

it("reports non-order violations in clampedViolations without altering orders", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test codifies returning an unclamped member-count violation in clampedViolations. Clamp mode should not hand back orders that still violate the composition. Reject unclampable violations or return them separately and make the caller handle them.

expect(result.violations).toHaveLength(0);
});

it("skips assets with no price when checking aggregate exposure", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The fake totalValue treats a missing price as zero. The real Portfolio.totalValue throws UnknownAssetError, so this does not test the actual behavior. Use the real portfolio or assert the typed error.

@collinsezedike collinsezedike changed the title feat(sdk): add composition constraint engine feat(strategies): add composition constraint engine Oct 25, 2026

@collinsezedike collinsezedike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for your contribution. The constraint engine is now integrated with post-trade validation, clamp handling, and coverage for the enforcement paths. Please pick up another open issue when you have time.

@collinsezedike
collinsezedike merged commit 4bfe48b into drydocs:main Oct 25, 2026
10 of 11 checks passed
@collinsezedike

Copy link
Copy Markdown
Collaborator

Thank you for contributing, @CodlithLab. A star on the repository would help others discover it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SDK] Add the composition constraint engine

2 participants