Repository navigation
feat(strategies): add composition constraint engine - #1225
collinsezedike merged 5 commits into
Conversation
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
|
@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! 🚀 |
|
@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
left a comment
There was a problem hiding this comment.
@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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
constraints is never used. tsconfig.json sets noUnusedParameters: true, so npx tsc --noEmit fails. Remove the parameter and update the callers, or use it.
| 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), | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
Thank you for contributing, @CodlithLab. A star on the repository would help others discover it. |
Add a pure constraint validator that enforces allocation and aggregate-exposure limits on a composition at init and after every rebalance.
Closes [SDK] Add the composition constraint engine #1199