@holoscript/plugin-banking-finance 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +13 -0
- package/src/__tests__/fixedincome.test.ts +337 -0
- package/src/__tests__/runtime-integration.test.ts +142 -0
- package/src/fixedincome.ts +362 -0
- package/src/index.ts +31 -0
- package/src/runtime.ts +172 -0
- package/src/traits/AccountTrait.ts +23 -0
- package/src/traits/KYCTrait.ts +22 -0
- package/src/traits/PortfolioTrait.ts +29 -0
- package/src/traits/RiskModelTrait.ts +21 -0
- package/src/traits/TransactionTrait.ts +23 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +22 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-banking-finance",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@holoscript/core": "8.0.6"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run --passWithNoTests",
|
|
11
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-income and portfolio analytics tests — banking-finance-plugin
|
|
3
|
+
*
|
|
4
|
+
* Reference values verified against:
|
|
5
|
+
* - Fabozzi "Fixed Income Mathematics" (4th ed.) worked examples
|
|
6
|
+
* - CFA Institute curriculum bond analytics
|
|
7
|
+
* - Black-Scholes (1973) original paper Table 1
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
analyzeBond,
|
|
13
|
+
analyzePortfolio,
|
|
14
|
+
blackScholes,
|
|
15
|
+
analyzeFinance,
|
|
16
|
+
buildFinanceReceipt,
|
|
17
|
+
type BondSpec,
|
|
18
|
+
type PortfolioHolding,
|
|
19
|
+
} from '../fixedincome';
|
|
20
|
+
|
|
21
|
+
// ─── Bond pricing ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('analyzeBond', () => {
|
|
24
|
+
/**
|
|
25
|
+
* 10-year semi-annual 5% coupon bond, par = $1000, yield = 5%.
|
|
26
|
+
* When coupon rate = yield, price = par: $1000.
|
|
27
|
+
*/
|
|
28
|
+
const parBond: BondSpec = {
|
|
29
|
+
faceValue: 1_000,
|
|
30
|
+
couponRate: 0.05,
|
|
31
|
+
periods: 20, // 10 years × 2
|
|
32
|
+
periodsPerYear: 2,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
it('par bond (coupon = yield) prices at face value', () => {
|
|
36
|
+
const r = analyzeBond(parBond, 0.05);
|
|
37
|
+
expect(r.price).toBeCloseTo(1000, 2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Premium bond: coupon 6%, yield 5%, 10yr semi-annual.
|
|
42
|
+
* Price > par (≈ $1077.22 from Fabozzi).
|
|
43
|
+
*/
|
|
44
|
+
it('premium bond (coupon > yield) prices above par', () => {
|
|
45
|
+
// C=30/period, r=2.5%/period, n=20 → Price = 30×ä + 1000×v^20 ≈ $1077.95
|
|
46
|
+
const bond: BondSpec = { faceValue: 1000, couponRate: 0.06, periods: 20, periodsPerYear: 2 };
|
|
47
|
+
const r = analyzeBond(bond, 0.05);
|
|
48
|
+
expect(r.price).toBeGreaterThan(1000);
|
|
49
|
+
expect(r.price).toBeCloseTo(1077.95, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discount bond: coupon 4%, yield 5%, 10yr semi-annual.
|
|
54
|
+
* C=20/period, r=2.5%/period, n=20 → Price ≈ $922.05
|
|
55
|
+
*/
|
|
56
|
+
it('discount bond (coupon < yield) prices below par', () => {
|
|
57
|
+
const bond: BondSpec = { faceValue: 1000, couponRate: 0.04, periods: 20, periodsPerYear: 2 };
|
|
58
|
+
const r = analyzeBond(bond, 0.05);
|
|
59
|
+
expect(r.price).toBeLessThan(1000);
|
|
60
|
+
expect(r.price).toBeCloseTo(922.05, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('price is inverse function of yield (higher yield → lower price)', () => {
|
|
64
|
+
const bond: BondSpec = { faceValue: 1000, couponRate: 0.05, periods: 20, periodsPerYear: 2 };
|
|
65
|
+
const r4 = analyzeBond(bond, 0.04);
|
|
66
|
+
const r6 = analyzeBond(bond, 0.06);
|
|
67
|
+
expect(r4.price).toBeGreaterThan(r6.price);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('Macaulay duration is always ≤ maturity (in years)', () => {
|
|
71
|
+
const r = analyzeBond(parBond, 0.05);
|
|
72
|
+
expect(r.macaulayDuration).toBeLessThanOrEqual(10 + 1e-6);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('modified duration < Macaulay duration', () => {
|
|
76
|
+
const r = analyzeBond(parBond, 0.05);
|
|
77
|
+
expect(r.modifiedDuration).toBeLessThan(r.macaulayDuration);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('zero-coupon bond: Macaulay duration equals maturity', () => {
|
|
81
|
+
const zcb: BondSpec = { faceValue: 1000, couponRate: 0, periods: 10, periodsPerYear: 1 };
|
|
82
|
+
const r = analyzeBond(zcb, 0.05);
|
|
83
|
+
expect(r.macaulayDuration).toBeCloseTo(10, 4);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('DV01 is positive (price rises when yield falls)', () => {
|
|
87
|
+
const r = analyzeBond(parBond, 0.05);
|
|
88
|
+
expect(r.dv01).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('convexity is positive', () => {
|
|
92
|
+
const r = analyzeBond(parBond, 0.05);
|
|
93
|
+
expect(r.convexity).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* YTM: par bond priced at $1000 with 5% coupon should have YTM = 5%.
|
|
98
|
+
*/
|
|
99
|
+
it('YTM from market price = face value equals coupon rate (par bond)', () => {
|
|
100
|
+
const bond: BondSpec = { ...parBond, marketPrice: 1000 };
|
|
101
|
+
const r = analyzeBond(bond, 0.05);
|
|
102
|
+
expect(r.ytm).not.toBeNull();
|
|
103
|
+
expect(r.ytm!).toBeCloseTo(0.05, 3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('YTM > coupon rate for discount bond', () => {
|
|
107
|
+
const bond: BondSpec = {
|
|
108
|
+
faceValue: 1000, couponRate: 0.04, periods: 20, periodsPerYear: 2, marketPrice: 922.78,
|
|
109
|
+
};
|
|
110
|
+
const r = analyzeBond(bond, 0.05);
|
|
111
|
+
expect(r.ytm).not.toBeNull();
|
|
112
|
+
expect(r.ytm!).toBeGreaterThan(0.04);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws for zero periods', () => {
|
|
116
|
+
expect(() => analyzeBond({ ...parBond, periods: 0 }, 0.05)).toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws for negative yield', () => {
|
|
120
|
+
expect(() => analyzeBond(parBond, -0.01)).toThrow();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ─── Portfolio analytics ──────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('analyzePortfolio', () => {
|
|
127
|
+
/** Monthly returns for 3 assets over 24 months (synthetic) */
|
|
128
|
+
const makeReturns = (seed: number, n = 24): number[] =>
|
|
129
|
+
Array.from({ length: n }, (_, i) => 0.007 + 0.02 * Math.sin(i * seed));
|
|
130
|
+
|
|
131
|
+
const holdings: PortfolioHolding[] = [
|
|
132
|
+
{ id: 'SPY', returns: makeReturns(0.7), weight: 0.5 },
|
|
133
|
+
{ id: 'AGG', returns: makeReturns(1.3), weight: 0.3 },
|
|
134
|
+
{ id: 'GLD', returns: makeReturns(2.1), weight: 0.2 },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
it('expected return is a weighted combination of asset returns', () => {
|
|
138
|
+
const r = analyzePortfolio(holdings);
|
|
139
|
+
// All assets have positive average return ≈ 0.007, so portfolio should too
|
|
140
|
+
expect(r.expectedReturn).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('stdDev is positive', () => {
|
|
144
|
+
const r = analyzePortfolio(holdings);
|
|
145
|
+
expect(r.stdDev).toBeGreaterThan(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('variance = stdDev²', () => {
|
|
149
|
+
const r = analyzePortfolio(holdings);
|
|
150
|
+
expect(r.variance).toBeCloseTo(r.stdDev ** 2, 10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('sharpeRatio is computed when riskFreeRate provided', () => {
|
|
154
|
+
const r = analyzePortfolio(holdings, 0.03);
|
|
155
|
+
expect(r.sharpeRatio).not.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('sharpeRatio is null when riskFreeRate not provided', () => {
|
|
159
|
+
const r = analyzePortfolio(holdings);
|
|
160
|
+
expect(r.sharpeRatio).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('beta is computed when benchmark returns provided', () => {
|
|
164
|
+
const benchmark = makeReturns(0.7); // correlated with SPY
|
|
165
|
+
const r = analyzePortfolio(holdings, 0.03, benchmark);
|
|
166
|
+
expect(r.beta).not.toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('beta is null when benchmark not provided', () => {
|
|
170
|
+
const r = analyzePortfolio(holdings, 0.03);
|
|
171
|
+
expect(r.beta).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('var95 and cvar95 are numbers', () => {
|
|
175
|
+
const r = analyzePortfolio(holdings);
|
|
176
|
+
expect(typeof r.var95).toBe('number');
|
|
177
|
+
expect(typeof r.cvar95).toBe('number');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('cvar95 ≥ var95 (expected shortfall ≥ VaR)', () => {
|
|
181
|
+
const r = analyzePortfolio(holdings);
|
|
182
|
+
expect(r.cvar95).toBeGreaterThanOrEqual(r.var95);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('throws when weights do not sum to 1', () => {
|
|
186
|
+
const bad: PortfolioHolding[] = [
|
|
187
|
+
{ id: 'A', returns: [0.01, 0.02], weight: 0.6 },
|
|
188
|
+
{ id: 'B', returns: [0.01, 0.02], weight: 0.6 },
|
|
189
|
+
];
|
|
190
|
+
expect(() => analyzePortfolio(bad)).toThrow();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('throws for mismatched return series lengths', () => {
|
|
194
|
+
const bad: PortfolioHolding[] = [
|
|
195
|
+
{ id: 'A', returns: [0.01, 0.02, 0.03], weight: 0.5 },
|
|
196
|
+
{ id: 'B', returns: [0.01, 0.02], weight: 0.5 },
|
|
197
|
+
];
|
|
198
|
+
expect(() => analyzePortfolio(bad)).toThrow();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('throws for fewer than 2 observations', () => {
|
|
202
|
+
expect(() =>
|
|
203
|
+
analyzePortfolio([{ id: 'A', returns: [0.01], weight: 1.0 }]),
|
|
204
|
+
).toThrow();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── Black-Scholes ────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe('blackScholes', () => {
|
|
211
|
+
/**
|
|
212
|
+
* Black-Scholes (1973) Table 1 reference case (approximated):
|
|
213
|
+
* S=40, K=40 (ATM), T=0.5yr, r=0.10, σ=0.40
|
|
214
|
+
* call ≈ $4.76, put ≈ $2.82 (from original paper, approximate)
|
|
215
|
+
*/
|
|
216
|
+
const atm = () => blackScholes(40, 40, 0.5, 0.10, 0.40);
|
|
217
|
+
|
|
218
|
+
it('ATM call price is positive and reasonable (0–10 for S=40)', () => {
|
|
219
|
+
const r = atm();
|
|
220
|
+
expect(r.callPrice).toBeGreaterThan(0);
|
|
221
|
+
expect(r.callPrice).toBeLessThan(10);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('ATM put price is positive', () => {
|
|
225
|
+
expect(atm().putPrice).toBeGreaterThan(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Put-call parity: C − P = S − K × e^(−rT)
|
|
230
|
+
*/
|
|
231
|
+
it('put-call parity holds', () => {
|
|
232
|
+
const r = atm();
|
|
233
|
+
const S = 40, K = 40, T = 0.5, rr = 0.10;
|
|
234
|
+
const parity = S - K * Math.exp(-rr * T);
|
|
235
|
+
expect(r.callPrice - r.putPrice).toBeCloseTo(parity, 3);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('call delta ∈ (0, 1)', () => {
|
|
239
|
+
expect(atm().callDelta).toBeGreaterThan(0);
|
|
240
|
+
expect(atm().callDelta).toBeLessThan(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('put delta ∈ (−1, 0)', () => {
|
|
244
|
+
expect(atm().putDelta).toBeLessThan(0);
|
|
245
|
+
expect(atm().putDelta).toBeGreaterThan(-1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('call delta + |put delta| ≈ 1', () => {
|
|
249
|
+
const r = atm();
|
|
250
|
+
expect(r.callDelta + Math.abs(r.putDelta)).toBeCloseTo(1, 6);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('gamma is positive (same for call and put)', () => {
|
|
254
|
+
expect(atm().gamma).toBeGreaterThan(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('vega is positive (higher vol → higher option value)', () => {
|
|
258
|
+
expect(atm().vega).toBeGreaterThan(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('deep ITM call price approaches intrinsic value (S - K × e^{-rT})', () => {
|
|
262
|
+
const r = blackScholes(100, 50, 0.5, 0.05, 0.10); // deep ITM
|
|
263
|
+
const intrinsic = 100 - 50 * Math.exp(-0.05 * 0.5);
|
|
264
|
+
expect(r.callPrice).toBeCloseTo(intrinsic, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('deep OTM call price approaches 0', () => {
|
|
268
|
+
const r = blackScholes(40, 200, 0.5, 0.05, 0.20); // deep OTM
|
|
269
|
+
expect(r.callPrice).toBeCloseTo(0, 3);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('higher volatility increases both call and put price', () => {
|
|
273
|
+
const lo = blackScholes(40, 40, 0.5, 0.05, 0.20);
|
|
274
|
+
const hi = blackScholes(40, 40, 0.5, 0.05, 0.40);
|
|
275
|
+
expect(hi.callPrice).toBeGreaterThan(lo.callPrice);
|
|
276
|
+
expect(hi.putPrice).toBeGreaterThan(lo.putPrice);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('throws for zero spot price', () => {
|
|
280
|
+
expect(() => blackScholes(0, 40, 0.5, 0.05, 0.20)).toThrow();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('throws for zero volatility', () => {
|
|
284
|
+
expect(() => blackScholes(40, 40, 0.5, 0.05, 0)).toThrow();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── analyzeFinance ───────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe('analyzeFinance', () => {
|
|
291
|
+
it('computes all three analyses when all inputs provided', () => {
|
|
292
|
+
const holdings: PortfolioHolding[] = [
|
|
293
|
+
{ id: 'A', returns: Array.from({length:24},(_,i)=>0.005+0.01*Math.cos(i)), weight: 0.6 },
|
|
294
|
+
{ id: 'B', returns: Array.from({length:24},(_,i)=>0.006+0.01*Math.sin(i)), weight: 0.4 },
|
|
295
|
+
];
|
|
296
|
+
const r = analyzeFinance({
|
|
297
|
+
bond: { spec: { faceValue: 1000, couponRate: 0.05, periods: 20, periodsPerYear: 2 }, annualYield: 0.05 },
|
|
298
|
+
portfolio: { holdings },
|
|
299
|
+
options: { S: 40, K: 40, T: 0.5, r: 0.05, sigma: 0.30 },
|
|
300
|
+
});
|
|
301
|
+
expect(r.bond).toBeDefined();
|
|
302
|
+
expect(r.portfolio).toBeDefined();
|
|
303
|
+
expect(r.options).toBeDefined();
|
|
304
|
+
expect(r.converged).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
describe('buildFinanceReceipt', () => {
|
|
311
|
+
const bondResult = analyzeFinance({
|
|
312
|
+
bond: { spec: { faceValue: 1000, couponRate: 0.05, periods: 20, periodsPerYear: 2 }, annualYield: 0.05 },
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('produces receipt with plugin=banking-finance and CAEL event', () => {
|
|
316
|
+
const receipt = buildFinanceReceipt(bondResult);
|
|
317
|
+
expect(receipt.plugin).toBe('banking-finance');
|
|
318
|
+
expect(receipt.cael.event).toBe('banking_finance.fixed_income');
|
|
319
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('accepted=true for valid bond analysis', () => {
|
|
323
|
+
const receipt = buildFinanceReceipt(bondResult);
|
|
324
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
325
|
+
expect(receipt.acceptance.violations).toHaveLength(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('resultSummary includes bond metrics', () => {
|
|
329
|
+
const receipt = buildFinanceReceipt(bondResult);
|
|
330
|
+
expect(receipt.resultSummary.bondDuration).toBeDefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('uses provided runId', () => {
|
|
334
|
+
const receipt = buildFinanceReceipt(bondResult, { runId: 'bond-run-01' });
|
|
335
|
+
expect(receipt.runId).toBe('bond-run-01');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof: the banking-finance `fixed_income_solver` trait, once
|
|
3
|
+
* registered via the runtime's real `registerTrait` seam, is dispatched BY THE
|
|
4
|
+
* RUNTIME and runs the deterministic bond-pricing solver — NOT called directly
|
|
5
|
+
* as a handler object.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors government-civic-plugin's runtime-integration reference
|
|
8
|
+
* (civic_decision). Drives the real path: executeNode(orb) -> orb-executor ->
|
|
9
|
+
* applyDirectives -> traitHandlers.get('fixed_income_solver').onAttach ->
|
|
10
|
+
* analyzeBond. The negative control proves the registration is load-bearing
|
|
11
|
+
* (without it, the trait is a dead no-op — which is exactly the tier's status
|
|
12
|
+
* quo).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
16
|
+
import { registerBankingFinanceTraitHandlers } from '../runtime';
|
|
17
|
+
|
|
18
|
+
// ── HAND-DERIVED bond case (pen-and-paper, NOT copied from solver output) ──────
|
|
19
|
+
// A 2-period annual bond, face = 1000, coupon = 10% (annual), periodsPerYear = 1,
|
|
20
|
+
// discounted at an annual yield of 5%. Chosen because it prices ABOVE par
|
|
21
|
+
// (coupon > yield) — a strictly stronger assertion than a par bond, and the
|
|
22
|
+
// arithmetic is tractable by hand.
|
|
23
|
+
//
|
|
24
|
+
// C (coupon per period) = faceValue × (couponRate / periodsPerYear)
|
|
25
|
+
// = 1000 × (0.10 / 1) = 100
|
|
26
|
+
// r (yield per period) = annualYield / periodsPerYear = 0.05 / 1 = 0.05
|
|
27
|
+
//
|
|
28
|
+
// price = Σ_{t=1..n} C/(1+r)^t + F/(1+r)^n
|
|
29
|
+
// = 100/1.05 + 100/1.05² (the two coupons)
|
|
30
|
+
// + 1000/1.05² (face at maturity)
|
|
31
|
+
// = 100/1.05 + 1100/1.1025
|
|
32
|
+
// = 95.238095… + 997.732426…
|
|
33
|
+
// = 1092.970522 → assert ≈ 1092.9705
|
|
34
|
+
//
|
|
35
|
+
// Macaulay duration = Σ (t/periodsPerYear)·PV(CF_t) / price
|
|
36
|
+
// CF₁ = 100, PV₁ = 100/1.05 = 95.238095…, weighted 1×95.238095… = 95.238095…
|
|
37
|
+
// CF₂ = 100+1000, PV₂ = 1100/1.1025 = 997.732426…, weighted 2×997.732426… = 1995.464853…
|
|
38
|
+
// Σ = 2090.702948… ; duration = 2090.702948… / 1092.970522… = 1.912863 yr
|
|
39
|
+
// → assert ≈ 1.9129
|
|
40
|
+
//
|
|
41
|
+
// Modified duration = macaulay / (1+r) = 1.912863 / 1.05 = 1.821774
|
|
42
|
+
// → assert ≈ 1.8218
|
|
43
|
+
const BOND_CONFIG = {
|
|
44
|
+
faceValue: 1000,
|
|
45
|
+
couponRate: 0.1,
|
|
46
|
+
periods: 2,
|
|
47
|
+
periodsPerYear: 1,
|
|
48
|
+
annualYield: 0.05,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const EXPECTED_PRICE = 1092.970522;
|
|
52
|
+
const EXPECTED_MACAULAY = 1.912863;
|
|
53
|
+
const EXPECTED_MODIFIED = 1.821774;
|
|
54
|
+
|
|
55
|
+
function fixedIncomeOrb(config: Record<string, unknown>): unknown {
|
|
56
|
+
return {
|
|
57
|
+
type: 'orb',
|
|
58
|
+
name: 'bond',
|
|
59
|
+
properties: {},
|
|
60
|
+
methods: [],
|
|
61
|
+
position: [0, 0, 0],
|
|
62
|
+
hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
|
|
63
|
+
directives: [{ type: 'trait', name: 'fixed_income_solver', config }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Flush the runtime's async emit dispatch so `on` listeners have fired. */
|
|
68
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
69
|
+
|
|
70
|
+
describe('banking-finance -> HoloScript runtime integration (fixed_income_solver)', () => {
|
|
71
|
+
it('runtime dispatch runs the bond-pricing solver for a registered @fixed_income_solver orb', async () => {
|
|
72
|
+
const runtime = new HoloScriptRuntime();
|
|
73
|
+
registerBankingFinanceTraitHandlers(runtime);
|
|
74
|
+
|
|
75
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
76
|
+
runtime.on('fixed_income_solver_solved', (e: unknown) => {
|
|
77
|
+
solved.push(e as Record<string, unknown>);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await runtime.executeNode(fixedIncomeOrb({ ...BOND_CONFIG }) as never);
|
|
81
|
+
await flush();
|
|
82
|
+
|
|
83
|
+
expect(solved).toHaveLength(1);
|
|
84
|
+
const summary = solved[0];
|
|
85
|
+
// Hand-checked (see derivation above): a premium bond priced at 1092.9705
|
|
86
|
+
// with Macaulay 1.9129 yr and modified duration 1.8218.
|
|
87
|
+
expect(summary.price as number).toBeCloseTo(EXPECTED_PRICE, 4);
|
|
88
|
+
expect(summary.macaulayDuration as number).toBeCloseTo(EXPECTED_MACAULAY, 4);
|
|
89
|
+
expect(summary.modifiedDuration as number).toBeCloseTo(EXPECTED_MODIFIED, 4);
|
|
90
|
+
// No marketPrice supplied, so YTM is null by contract.
|
|
91
|
+
expect(summary.ytm).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('NEGATIVE CONTROL: without registration the @fixed_income_solver trait is a dead no-op', async () => {
|
|
95
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
96
|
+
const solved: unknown[] = [];
|
|
97
|
+
runtime.on('fixed_income_solver_solved', (e: unknown) => solved.push(e));
|
|
98
|
+
|
|
99
|
+
await runtime.executeNode(fixedIncomeOrb({ ...BOND_CONFIG }) as never);
|
|
100
|
+
await flush();
|
|
101
|
+
|
|
102
|
+
expect(solved).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('persists the solver result into durable runtime state on ATTACH', async () => {
|
|
106
|
+
const runtime = new HoloScriptRuntime();
|
|
107
|
+
registerBankingFinanceTraitHandlers(runtime);
|
|
108
|
+
|
|
109
|
+
await runtime.executeNode(fixedIncomeOrb({ ...BOND_CONFIG }) as never);
|
|
110
|
+
await flush();
|
|
111
|
+
|
|
112
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
113
|
+
const persisted = state['fixed_income_solver:bond'] as
|
|
114
|
+
| { price?: number; modifiedDuration?: number }
|
|
115
|
+
| undefined;
|
|
116
|
+
expect(persisted).toBeDefined();
|
|
117
|
+
// Same hand-checked winner value, now read from durable state.
|
|
118
|
+
expect(persisted?.price).toBeCloseTo(EXPECTED_PRICE, 4);
|
|
119
|
+
expect(persisted?.modifiedDuration).toBeCloseTo(EXPECTED_MODIFIED, 4);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('emits fixed_income_solver_error (does not throw through the runtime) for invalid config', async () => {
|
|
123
|
+
const runtime = new HoloScriptRuntime();
|
|
124
|
+
registerBankingFinanceTraitHandlers(runtime);
|
|
125
|
+
|
|
126
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
127
|
+
runtime.on('fixed_income_solver_error', (e: unknown) => {
|
|
128
|
+
errors.push(e as Record<string, unknown>);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Missing the required `faceValue` field — the handler's config validation
|
|
132
|
+
// emits a single fixed_income_solver_error rather than throwing through the
|
|
133
|
+
// runtime. (The same single-error path also covers cases where analyzeBond
|
|
134
|
+
// itself throws, e.g. periods: 0 or a negative annualYield, via try/catch.)
|
|
135
|
+
const { faceValue: _omitted, ...withoutFaceValue } = BOND_CONFIG;
|
|
136
|
+
await runtime.executeNode(fixedIncomeOrb({ ...withoutFaceValue }) as never);
|
|
137
|
+
await flush();
|
|
138
|
+
|
|
139
|
+
expect(errors).toHaveLength(1);
|
|
140
|
+
expect(String(errors[0].error)).toContain('faceValue');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-income and portfolio analytics solver — banking-finance-plugin
|
|
3
|
+
*
|
|
4
|
+
* Implements standard financial mathematics without external dependencies:
|
|
5
|
+
* - Bond pricing (NPV of coupon + face cashflows)
|
|
6
|
+
* - Yield-to-maturity (bisection on price function)
|
|
7
|
+
* - Modified/Macaulay duration and convexity
|
|
8
|
+
* - Portfolio analytics: return, variance, Sharpe ratio, beta, VaR (historical)
|
|
9
|
+
* - Black-Scholes option pricing (European call/put)
|
|
10
|
+
* - CAEL-backed receipt
|
|
11
|
+
*
|
|
12
|
+
* References:
|
|
13
|
+
* Fabozzi, F. "Fixed Income Mathematics" (4th ed., 2006).
|
|
14
|
+
* Markowitz, H. (1952) "Portfolio Selection." J. Finance 7(1):77-91.
|
|
15
|
+
* Black & Scholes (1973) "The Pricing of Options..." J. Political Economy.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DOMAIN_SIMULATION_RECEIPT_SCHEMA,
|
|
20
|
+
buildDomainSimulationReceipt,
|
|
21
|
+
} from '@holoscript/core';
|
|
22
|
+
|
|
23
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface BondSpec {
|
|
26
|
+
/** Face (par) value */
|
|
27
|
+
faceValue: number;
|
|
28
|
+
/** Annual coupon rate (e.g. 0.05 = 5%) */
|
|
29
|
+
couponRate: number;
|
|
30
|
+
/** Periods to maturity (coupon payments; typically semi-annual = years × 2) */
|
|
31
|
+
periods: number;
|
|
32
|
+
/** Periods per year (2 = semi-annual, 1 = annual) */
|
|
33
|
+
periodsPerYear: number;
|
|
34
|
+
/** Market price (for YTM calculation; optional) */
|
|
35
|
+
marketPrice?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BondResult {
|
|
39
|
+
/** Clean price (sum of discounted cash flows) at given yield */
|
|
40
|
+
price: number;
|
|
41
|
+
/** Yield to maturity (annual, bond-equivalent) */
|
|
42
|
+
ytm: number | null;
|
|
43
|
+
/** Macaulay duration (years) */
|
|
44
|
+
macaulayDuration: number;
|
|
45
|
+
/** Modified duration (% price change per 1% yield change) */
|
|
46
|
+
modifiedDuration: number;
|
|
47
|
+
/** Dollar duration (DV01): price change for 1 bp yield change */
|
|
48
|
+
dv01: number;
|
|
49
|
+
/** Convexity */
|
|
50
|
+
convexity: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PortfolioHolding {
|
|
54
|
+
id: string;
|
|
55
|
+
/** Historical returns (one per period) */
|
|
56
|
+
returns: number[];
|
|
57
|
+
/** Portfolio weight (0–1, must sum to 1 across holdings) */
|
|
58
|
+
weight: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PortfolioResult {
|
|
62
|
+
expectedReturn: number;
|
|
63
|
+
variance: number;
|
|
64
|
+
stdDev: number;
|
|
65
|
+
sharpeRatio: number | null; // null if riskFreeRate not provided
|
|
66
|
+
beta: number | null; // null if benchmark returns not provided
|
|
67
|
+
/** Historical VaR at 95% confidence */
|
|
68
|
+
var95: number;
|
|
69
|
+
/** Historical CVaR (expected shortfall) at 95% */
|
|
70
|
+
cvar95: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BlackScholesResult {
|
|
74
|
+
callPrice: number;
|
|
75
|
+
putPrice: number;
|
|
76
|
+
/** Greeks */
|
|
77
|
+
callDelta: number;
|
|
78
|
+
putDelta: number;
|
|
79
|
+
gamma: number;
|
|
80
|
+
vega: number;
|
|
81
|
+
callTheta: number;
|
|
82
|
+
putTheta: number;
|
|
83
|
+
callRho: number;
|
|
84
|
+
putRho: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FinanceAnalysisResult {
|
|
88
|
+
bond?: BondResult;
|
|
89
|
+
portfolio?: PortfolioResult;
|
|
90
|
+
options?: BlackScholesResult;
|
|
91
|
+
converged: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface FinanceReceipt {
|
|
95
|
+
plugin: string;
|
|
96
|
+
runId: string;
|
|
97
|
+
payloadHash: string;
|
|
98
|
+
hashAlgorithm: string;
|
|
99
|
+
cael: { event: string; solverType: string; version: string };
|
|
100
|
+
acceptance: { accepted: boolean; violations: Array<{ criterion: string; message: string }> };
|
|
101
|
+
resultSummary: {
|
|
102
|
+
bondYTM?: number;
|
|
103
|
+
bondDuration?: number;
|
|
104
|
+
portfolioReturn?: number;
|
|
105
|
+
portfolioSharpe?: number;
|
|
106
|
+
optionCallPrice?: number;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Normal distribution helpers ─────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Standard normal CDF via Abramowitz & Stegun (7-term; error < 3e-7) */
|
|
113
|
+
function normalCDF(x: number): number {
|
|
114
|
+
if (x < -8) return 0;
|
|
115
|
+
if (x > 8) return 1;
|
|
116
|
+
const k = 1 / (1 + 0.2316419 * Math.abs(x));
|
|
117
|
+
const poly = ((((1.330274429 * k - 1.821255978) * k + 1.781477937) * k
|
|
118
|
+
- 0.356563782) * k + 0.319381530) * k;
|
|
119
|
+
const pdf = Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math.PI);
|
|
120
|
+
const cdf = 1 - pdf * poly;
|
|
121
|
+
return x >= 0 ? cdf : 1 - cdf;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Standard normal PDF */
|
|
125
|
+
function normalPDF(x: number): number {
|
|
126
|
+
return Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math.PI);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Bond pricing ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Price a bond given a yield per period.
|
|
133
|
+
* price = Σ(t=1..n) C/(1+r)^t + F/(1+r)^n
|
|
134
|
+
*/
|
|
135
|
+
function bondPrice(spec: BondSpec, yieldPerPeriod: number): number {
|
|
136
|
+
const { faceValue, couponRate, periods, periodsPerYear } = spec;
|
|
137
|
+
const C = faceValue * (couponRate / periodsPerYear); // coupon per period
|
|
138
|
+
let price = 0;
|
|
139
|
+
for (let t = 1; t <= periods; t++) {
|
|
140
|
+
price += C / Math.pow(1 + yieldPerPeriod, t);
|
|
141
|
+
}
|
|
142
|
+
price += faceValue / Math.pow(1 + yieldPerPeriod, periods);
|
|
143
|
+
return price;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compute bond analytics at a given annual yield.
|
|
148
|
+
*/
|
|
149
|
+
export function analyzeBond(spec: BondSpec, annualYield: number): BondResult {
|
|
150
|
+
const { faceValue, couponRate, periods, periodsPerYear } = spec;
|
|
151
|
+
if (periods <= 0) throw new Error('[finance] periods must be > 0');
|
|
152
|
+
if (faceValue <= 0) throw new Error('[finance] faceValue must be > 0');
|
|
153
|
+
if (periodsPerYear <= 0) throw new Error('[finance] periodsPerYear must be > 0');
|
|
154
|
+
if (annualYield < 0) throw new Error('[finance] annualYield must be ≥ 0');
|
|
155
|
+
|
|
156
|
+
const r = annualYield / periodsPerYear; // yield per period
|
|
157
|
+
const C = faceValue * (couponRate / periodsPerYear);
|
|
158
|
+
const price = bondPrice(spec, r);
|
|
159
|
+
|
|
160
|
+
// Macaulay duration: Σ t × PV(CF_t) / price
|
|
161
|
+
let durationWeightedSum = 0;
|
|
162
|
+
let convexitySum = 0;
|
|
163
|
+
for (let t = 1; t <= periods; t++) {
|
|
164
|
+
const cf = t < periods ? C : C + faceValue;
|
|
165
|
+
const pv = cf / Math.pow(1 + r, t);
|
|
166
|
+
durationWeightedSum += (t / periodsPerYear) * pv;
|
|
167
|
+
convexitySum += (t * (t + 1)) * pv / Math.pow(1 + r, 2);
|
|
168
|
+
}
|
|
169
|
+
const macaulayDuration = durationWeightedSum / price;
|
|
170
|
+
const modifiedDuration = macaulayDuration / (1 + r);
|
|
171
|
+
const dv01 = modifiedDuration * price * 0.0001; // per 1 bp
|
|
172
|
+
const convexity = convexitySum / (price * Math.pow(periodsPerYear, 2));
|
|
173
|
+
|
|
174
|
+
// YTM from market price (if provided)
|
|
175
|
+
let ytm: number | null = null;
|
|
176
|
+
if (spec.marketPrice !== undefined && spec.marketPrice > 0) {
|
|
177
|
+
const mktPrice = spec.marketPrice;
|
|
178
|
+
// Bisect on annual yield: find y s.t. bondPrice(y/periodsPerYear) = mktPrice
|
|
179
|
+
let lo = 0, hi = 2.0;
|
|
180
|
+
if (Math.sign(bondPrice(spec, lo / periodsPerYear) - mktPrice) !==
|
|
181
|
+
Math.sign(bondPrice(spec, hi / periodsPerYear) - mktPrice)) {
|
|
182
|
+
for (let i = 0; i < 100; i++) {
|
|
183
|
+
const mid = (lo + hi) / 2;
|
|
184
|
+
const delta = bondPrice(spec, mid / periodsPerYear) - mktPrice;
|
|
185
|
+
if (Math.abs(delta) < 0.0001 || (hi - lo) < 1e-10) { ytm = mid; break; }
|
|
186
|
+
Math.sign(delta) === Math.sign(bondPrice(spec, lo / periodsPerYear) - mktPrice)
|
|
187
|
+
? (lo = mid) : (hi = mid);
|
|
188
|
+
ytm = (lo + hi) / 2;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { price, ytm, macaulayDuration, modifiedDuration, dv01, convexity };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Portfolio analytics ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compute portfolio-level risk and return metrics.
|
|
200
|
+
*
|
|
201
|
+
* @param holdings Assets with weights and return history (same-length arrays)
|
|
202
|
+
* @param riskFreeRate Annual risk-free rate (e.g. 0.04); optional — required for Sharpe
|
|
203
|
+
* @param benchmarkReturns Benchmark return series; optional — required for beta
|
|
204
|
+
*/
|
|
205
|
+
export function analyzePortfolio(
|
|
206
|
+
holdings: PortfolioHolding[],
|
|
207
|
+
riskFreeRate?: number,
|
|
208
|
+
benchmarkReturns?: number[],
|
|
209
|
+
): PortfolioResult {
|
|
210
|
+
if (holdings.length === 0) throw new Error('[finance] at least one holding required');
|
|
211
|
+
const n = holdings[0].returns.length;
|
|
212
|
+
if (n < 2) throw new Error('[finance] at least 2 return observations required');
|
|
213
|
+
if (holdings.some((h) => h.returns.length !== n)) throw new Error('[finance] all return series must have same length');
|
|
214
|
+
|
|
215
|
+
const totalWeight = holdings.reduce((s, h) => s + h.weight, 0);
|
|
216
|
+
if (Math.abs(totalWeight - 1) > 1e-6) throw new Error(`[finance] weights must sum to 1 (got ${totalWeight.toFixed(6)})`);
|
|
217
|
+
|
|
218
|
+
// Portfolio returns (weighted sum each period)
|
|
219
|
+
const portReturns = Array.from({ length: n }, (_, t) =>
|
|
220
|
+
holdings.reduce((s, h) => s + h.weight * h.returns[t], 0),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const expectedReturn = portReturns.reduce((s, r) => s + r, 0) / n;
|
|
224
|
+
const variance = portReturns.reduce((s, r) => s + (r - expectedReturn) ** 2, 0) / (n - 1);
|
|
225
|
+
const stdDev = Math.sqrt(variance);
|
|
226
|
+
|
|
227
|
+
// Sharpe ratio (annualised using period count as proxy)
|
|
228
|
+
let sharpeRatio: number | null = null;
|
|
229
|
+
if (riskFreeRate !== undefined) {
|
|
230
|
+
const periodsPerYear = n; // single-period approximation
|
|
231
|
+
const excessReturn = expectedReturn - riskFreeRate / periodsPerYear;
|
|
232
|
+
sharpeRatio = stdDev > 0 ? (excessReturn / stdDev) * Math.sqrt(n) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Beta (CAPM: β = Cov(Rp, Rm) / Var(Rm))
|
|
236
|
+
let beta: number | null = null;
|
|
237
|
+
if (benchmarkReturns && benchmarkReturns.length === n) {
|
|
238
|
+
const benchMean = benchmarkReturns.reduce((s, r) => s + r, 0) / n;
|
|
239
|
+
const cov = portReturns.reduce((s, r, i) => s + (r - expectedReturn) * (benchmarkReturns[i] - benchMean), 0) / (n - 1);
|
|
240
|
+
const benchVar = benchmarkReturns.reduce((s, r) => s + (r - benchMean) ** 2, 0) / (n - 1);
|
|
241
|
+
beta = benchVar > 0 ? cov / benchVar : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Historical VaR and CVaR at 95%
|
|
245
|
+
const sorted = [...portReturns].sort((a, b) => a - b);
|
|
246
|
+
const idx = Math.ceil(0.05 * n) - 1;
|
|
247
|
+
const var95 = -sorted[Math.max(0, idx)]; // convert return to loss
|
|
248
|
+
const tail = sorted.slice(0, idx + 1);
|
|
249
|
+
const cvar95 = -(tail.reduce((s, v) => s + v, 0) / tail.length);
|
|
250
|
+
|
|
251
|
+
return { expectedReturn, variance, stdDev, sharpeRatio, beta, var95, cvar95 };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Black-Scholes ────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Black-Scholes European option pricing with full Greeks.
|
|
258
|
+
*
|
|
259
|
+
* @param S Spot price
|
|
260
|
+
* @param K Strike price
|
|
261
|
+
* @param T Time to expiry (years)
|
|
262
|
+
* @param r Risk-free rate (annual, continuously compounded)
|
|
263
|
+
* @param σ Volatility (annual)
|
|
264
|
+
*/
|
|
265
|
+
export function blackScholes(S: number, K: number, T: number, r: number, sigma: number): BlackScholesResult {
|
|
266
|
+
if (S <= 0) throw new Error('[finance] spot price must be > 0');
|
|
267
|
+
if (K <= 0) throw new Error('[finance] strike must be > 0');
|
|
268
|
+
if (T <= 0) throw new Error('[finance] time to expiry must be > 0');
|
|
269
|
+
if (sigma <= 0) throw new Error('[finance] volatility must be > 0');
|
|
270
|
+
|
|
271
|
+
const sqrtT = Math.sqrt(T);
|
|
272
|
+
const d1 = (Math.log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrtT);
|
|
273
|
+
const d2 = d1 - sigma * sqrtT;
|
|
274
|
+
|
|
275
|
+
const Nd1 = normalCDF(d1);
|
|
276
|
+
const Nd2 = normalCDF(d2);
|
|
277
|
+
const Nnd1 = normalCDF(-d1);
|
|
278
|
+
const Nnd2 = normalCDF(-d2);
|
|
279
|
+
const nd1 = normalPDF(d1);
|
|
280
|
+
const disc = Math.exp(-r * T);
|
|
281
|
+
|
|
282
|
+
const callPrice = S * Nd1 - K * disc * Nd2;
|
|
283
|
+
const putPrice = K * disc * Nnd2 - S * Nnd1;
|
|
284
|
+
|
|
285
|
+
const callDelta = Nd1;
|
|
286
|
+
const putDelta = Nd1 - 1;
|
|
287
|
+
const gamma = nd1 / (S * sigma * sqrtT);
|
|
288
|
+
const vega = S * nd1 * sqrtT / 100; // per 1% vol move
|
|
289
|
+
const callTheta = (-(S * nd1 * sigma) / (2 * sqrtT) - r * K * disc * Nd2) / 365;
|
|
290
|
+
const putTheta = (-(S * nd1 * sigma) / (2 * sqrtT) + r * K * disc * Nnd2) / 365;
|
|
291
|
+
const callRho = K * T * disc * Nd2 / 100;
|
|
292
|
+
const putRho = -K * T * disc * Nnd2 / 100;
|
|
293
|
+
|
|
294
|
+
return { callPrice, putPrice, callDelta, putDelta, gamma, vega, callTheta, putTheta, callRho, putRho };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Combined analysis ────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export function analyzeFinance(params: {
|
|
300
|
+
bond?: { spec: BondSpec; annualYield: number };
|
|
301
|
+
portfolio?: { holdings: PortfolioHolding[]; riskFreeRate?: number; benchmarkReturns?: number[] };
|
|
302
|
+
options?: { S: number; K: number; T: number; r: number; sigma: number };
|
|
303
|
+
}): FinanceAnalysisResult {
|
|
304
|
+
const result: FinanceAnalysisResult = { converged: true };
|
|
305
|
+
if (params.bond) result.bond = analyzeBond(params.bond.spec, params.bond.annualYield);
|
|
306
|
+
if (params.portfolio) result.portfolio = analyzePortfolio(
|
|
307
|
+
params.portfolio.holdings, params.portfolio.riskFreeRate, params.portfolio.benchmarkReturns,
|
|
308
|
+
);
|
|
309
|
+
if (params.options) {
|
|
310
|
+
const { S, K, T, r, sigma } = params.options;
|
|
311
|
+
result.options = blackScholes(S, K, T, r, sigma);
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Receipt ─────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
export function buildFinanceReceipt(
|
|
319
|
+
result: FinanceAnalysisResult,
|
|
320
|
+
options?: { runId?: string },
|
|
321
|
+
): FinanceReceipt {
|
|
322
|
+
const violations: Array<{ criterion: string; message: string }> = [];
|
|
323
|
+
if (result.bond?.price !== undefined && result.bond.price <= 0)
|
|
324
|
+
violations.push({ criterion: 'bond_price', message: 'computed bond price ≤ 0' });
|
|
325
|
+
|
|
326
|
+
const summary: Record<string, number | null | undefined> = {};
|
|
327
|
+
if (result.bond) {
|
|
328
|
+
summary.bondYTM = result.bond.ytm;
|
|
329
|
+
summary.bondDuration = result.bond.modifiedDuration;
|
|
330
|
+
}
|
|
331
|
+
if (result.portfolio) {
|
|
332
|
+
summary.portfolioReturn = result.portfolio.expectedReturn;
|
|
333
|
+
summary.portfolioSharpe = result.portfolio.sharpeRatio;
|
|
334
|
+
}
|
|
335
|
+
if (result.options) {
|
|
336
|
+
summary.optionCallPrice = result.options.callPrice;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const raw = buildDomainSimulationReceipt({
|
|
340
|
+
plugin: 'banking-finance',
|
|
341
|
+
pluginVersion: '1.0.0',
|
|
342
|
+
runId: options?.runId ?? `fin-${Date.now().toString(36)}`,
|
|
343
|
+
solverConfig: {
|
|
344
|
+
solverType: 'fixed-income-portfolio',
|
|
345
|
+
scale: 'instrument',
|
|
346
|
+
hasBond: result.bond !== undefined,
|
|
347
|
+
hasPortfolio: result.portfolio !== undefined,
|
|
348
|
+
hasOptions: result.options !== undefined,
|
|
349
|
+
},
|
|
350
|
+
resultSummary: Object.fromEntries(
|
|
351
|
+
Object.entries(summary).filter(([, v]) => v !== undefined && v !== null),
|
|
352
|
+
) as Record<string, number>,
|
|
353
|
+
cael: {
|
|
354
|
+
version: 'cael.v1',
|
|
355
|
+
event: 'banking_finance.fixed_income',
|
|
356
|
+
solverType: 'banking-finance.fixed-income-portfolio',
|
|
357
|
+
},
|
|
358
|
+
acceptance: { accepted: violations.length === 0, violations },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return raw as unknown as FinanceReceipt;
|
|
362
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { createAccountHandler, type AccountConfig, type AccountType } from './traits/AccountTrait';
|
|
2
|
+
export { createTransactionHandler, type TransactionConfig, type TransactionType, type TransactionStatus } from './traits/TransactionTrait';
|
|
3
|
+
export { createKYCHandler, type KYCConfig, type KYCLevel, type KYCStatus } from './traits/KYCTrait';
|
|
4
|
+
export { createPortfolioHandler, type PortfolioConfig, type Holding } from './traits/PortfolioTrait';
|
|
5
|
+
export { createRiskModelHandler, type RiskModelConfig, type RiskCategory } from './traits/RiskModelTrait';
|
|
6
|
+
export * from './traits/types';
|
|
7
|
+
|
|
8
|
+
import { createAccountHandler } from './traits/AccountTrait';
|
|
9
|
+
import { createTransactionHandler } from './traits/TransactionTrait';
|
|
10
|
+
import { createKYCHandler } from './traits/KYCTrait';
|
|
11
|
+
import { createPortfolioHandler } from './traits/PortfolioTrait';
|
|
12
|
+
import { createRiskModelHandler } from './traits/RiskModelTrait';
|
|
13
|
+
|
|
14
|
+
export * from './fixedincome';
|
|
15
|
+
|
|
16
|
+
export const pluginMeta = { name: '@holoscript/plugin-banking-finance', version: '1.0.0', traits: ['account', 'transaction', 'kyc', 'portfolio', 'risk_model', 'fixed_income_solver'] };
|
|
17
|
+
export const traitHandlers = [createAccountHandler(), createTransactionHandler(), createKYCHandler(), createPortfolioHandler(), createRiskModelHandler()];
|
|
18
|
+
|
|
19
|
+
// Runtime integration — behavioral trait handler + registrar that wire the
|
|
20
|
+
// deterministic bond-pricing solver into HoloScriptRuntime's dispatch. Closes
|
|
21
|
+
// the built-but-dead-wired gap for `fixed_income_solver`, mirroring
|
|
22
|
+
// government-civic's `civic_decision` reference integration.
|
|
23
|
+
export {
|
|
24
|
+
BANKING_FINANCE_PLUGIN_ID,
|
|
25
|
+
fixedIncomeSolverHandler,
|
|
26
|
+
registerBankingFinanceTraitHandlers,
|
|
27
|
+
type FixedIncomeSolverTraitConfig,
|
|
28
|
+
type FixedIncomeSolverSolvedEvent,
|
|
29
|
+
type RuntimeTraitHandler,
|
|
30
|
+
type TraitRegistrar,
|
|
31
|
+
} from './runtime';
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime integration for @holoscript/plugin-banking-finance.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the previously dead-wired `fixed_income_solver` trait into a
|
|
5
|
+
* behavioral TraitHandler that the HoloScript runtime actually dispatches
|
|
6
|
+
* (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
|
|
7
|
+
*
|
|
8
|
+
* Before this module the plugin declared trait NAMES only (pluginMeta.traits)
|
|
9
|
+
* and exported the fixed-income solvers (analyzeBond, analyzePortfolio,
|
|
10
|
+
* blackScholes, …), but nothing invoked a solver THROUGH the runtime — the
|
|
11
|
+
* whole domain-plugin tier was built-but-dead-wired. This mirrors
|
|
12
|
+
* government-civic-plugin's reference integration (civic_decision): it wires the
|
|
13
|
+
* deterministic bond-pricing solver (`analyzeBond`) behind the
|
|
14
|
+
* `fixed_income_solver` trait so the runtime's directive dispatch can run it.
|
|
15
|
+
* The remaining banking-finance traits follow the same registrar shape.
|
|
16
|
+
*/
|
|
17
|
+
import { registerPluginTraits } from '@holoscript/core/runtime';
|
|
18
|
+
import { analyzeBond, type BondSpec, type BondResult } from './fixedincome';
|
|
19
|
+
|
|
20
|
+
/** Stable id for this plugin's trait ownership tagging. */
|
|
21
|
+
export const BANKING_FINANCE_PLUGIN_ID = 'banking-finance' as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Config carried by an orb's `@fixed_income_solver` trait directive. Mirrors the
|
|
25
|
+
* `analyzeBond(spec, annualYield)` parameters as a flat directive payload: the
|
|
26
|
+
* BondSpec fields plus the annual yield to discount at. All four spec fields and
|
|
27
|
+
* `annualYield` are required; absence emits `fixed_income_solver_error`.
|
|
28
|
+
*/
|
|
29
|
+
export interface FixedIncomeSolverTraitConfig {
|
|
30
|
+
/** Face (par) value of the bond. Required; absence emits `fixed_income_solver_error`. */
|
|
31
|
+
faceValue?: number;
|
|
32
|
+
/** Annual coupon rate (e.g. 0.05 = 5%). Required. */
|
|
33
|
+
couponRate?: number;
|
|
34
|
+
/** Periods to maturity (coupon payments; semi-annual = years × 2). Required. */
|
|
35
|
+
periods?: number;
|
|
36
|
+
/** Periods per year (2 = semi-annual, 1 = annual). Required. */
|
|
37
|
+
periodsPerYear?: number;
|
|
38
|
+
/** Annual yield to discount the cashflows at (e.g. 0.05 = 5%). Required. */
|
|
39
|
+
annualYield?: number;
|
|
40
|
+
/** Optional market price; when present the solver also bisects for YTM. */
|
|
41
|
+
marketPrice?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Summary payload emitted on `fixed_income_solver_solved`. */
|
|
45
|
+
export interface FixedIncomeSolverSolvedEvent {
|
|
46
|
+
nodeId: string;
|
|
47
|
+
/** Clean price (sum of discounted cash flows) at the given annual yield. */
|
|
48
|
+
price: number;
|
|
49
|
+
/** Macaulay duration in years. */
|
|
50
|
+
macaulayDuration: number;
|
|
51
|
+
/** Modified duration (% price change per 1% yield change). */
|
|
52
|
+
modifiedDuration: number;
|
|
53
|
+
/** Dollar duration (DV01): price change for a 1 bp yield change. */
|
|
54
|
+
dv01: number;
|
|
55
|
+
/** Convexity. */
|
|
56
|
+
convexity: number;
|
|
57
|
+
/** Yield to maturity (annual, bond-equivalent) — null unless marketPrice given. */
|
|
58
|
+
ytm: number | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Structural view of the runtime trait-handler contract. Matches
|
|
63
|
+
* `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
|
|
64
|
+
* actually uses (onAttach / onUpdate receive the node, the directive config,
|
|
65
|
+
* and a context exposing `emit`). Declared locally so the plugin stays
|
|
66
|
+
* decoupled from core's full trait surface.
|
|
67
|
+
*/
|
|
68
|
+
export interface TraitDispatchContext {
|
|
69
|
+
emit: (event: string, payload?: unknown) => void;
|
|
70
|
+
setState?: (updates: Record<string, unknown>) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RuntimeTraitHandler {
|
|
74
|
+
name: string;
|
|
75
|
+
onAttach?: (node: unknown, config: FixedIncomeSolverTraitConfig, context: TraitDispatchContext) => void;
|
|
76
|
+
onUpdate?: (
|
|
77
|
+
node: unknown,
|
|
78
|
+
config: FixedIncomeSolverTraitConfig,
|
|
79
|
+
context: TraitDispatchContext,
|
|
80
|
+
delta: number,
|
|
81
|
+
) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface FixedIncomeSolverNode {
|
|
85
|
+
id?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
properties?: Record<string, unknown>;
|
|
88
|
+
__fixedIncomeResult?: BondResult;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Run the bond solver on the directive config, write the result onto the node, and emit. */
|
|
92
|
+
function solveOntoNode(
|
|
93
|
+
node: unknown,
|
|
94
|
+
config: FixedIncomeSolverTraitConfig | undefined,
|
|
95
|
+
context: TraitDispatchContext,
|
|
96
|
+
): void {
|
|
97
|
+
const carrier = node as FixedIncomeSolverNode;
|
|
98
|
+
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
99
|
+
const { faceValue, couponRate, periods, periodsPerYear, annualYield, marketPrice } = config ?? {};
|
|
100
|
+
|
|
101
|
+
// All bond-spec fields plus the annual yield are required. couponRate may be 0
|
|
102
|
+
// (zero-coupon bond), so it is checked for presence, not truthiness.
|
|
103
|
+
if (
|
|
104
|
+
faceValue === undefined ||
|
|
105
|
+
couponRate === undefined ||
|
|
106
|
+
periods === undefined ||
|
|
107
|
+
periodsPerYear === undefined ||
|
|
108
|
+
annualYield === undefined
|
|
109
|
+
) {
|
|
110
|
+
context.emit('fixed_income_solver_error', {
|
|
111
|
+
nodeId,
|
|
112
|
+
error:
|
|
113
|
+
'fixed_income_solver trait requires config.faceValue, config.couponRate, config.periods, config.periodsPerYear and config.annualYield',
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const spec: BondSpec = { faceValue, couponRate, periods, periodsPerYear, marketPrice };
|
|
120
|
+
const result = analyzeBond(spec, annualYield);
|
|
121
|
+
carrier.__fixedIncomeResult = result;
|
|
122
|
+
carrier.properties = {
|
|
123
|
+
...(carrier.properties ?? {}),
|
|
124
|
+
fixedIncomePrice: result.price,
|
|
125
|
+
fixedIncomeModifiedDuration: result.modifiedDuration,
|
|
126
|
+
};
|
|
127
|
+
const summary: FixedIncomeSolverSolvedEvent = {
|
|
128
|
+
nodeId,
|
|
129
|
+
price: result.price,
|
|
130
|
+
macaulayDuration: result.macaulayDuration,
|
|
131
|
+
modifiedDuration: result.modifiedDuration,
|
|
132
|
+
dv01: result.dv01,
|
|
133
|
+
convexity: result.convexity,
|
|
134
|
+
ytm: result.ytm,
|
|
135
|
+
};
|
|
136
|
+
context.setState?.({ [`fixed_income_solver:${nodeId}`]: summary });
|
|
137
|
+
context.emit('fixed_income_solver_solved', summary);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
context.emit('fixed_income_solver_error', {
|
|
140
|
+
nodeId,
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Behavioral handler for the banking-finance `fixed_income_solver` trait. Runs
|
|
148
|
+
* the deterministic bond-pricing solver whenever an orb carrying the trait is
|
|
149
|
+
* attached (and on each per-frame update), writing the result onto the node and
|
|
150
|
+
* emitting `fixed_income_solver_solved` / `fixed_income_solver_error`.
|
|
151
|
+
*/
|
|
152
|
+
export const fixedIncomeSolverHandler: RuntimeTraitHandler = {
|
|
153
|
+
name: 'fixed_income_solver',
|
|
154
|
+
onAttach: (node, config, context) => solveOntoNode(node, config, context),
|
|
155
|
+
onUpdate: (node, config, context) => solveOntoNode(node, config, context),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** A runtime that can register behavioral trait handlers. */
|
|
159
|
+
export interface TraitRegistrar {
|
|
160
|
+
registerTrait(name: string, handler: unknown): void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Register banking-finance behavioral trait handlers into a runtime that
|
|
165
|
+
* exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
|
|
166
|
+
* HoloScriptRuntime. This is the consumption path the dead-wired tier was
|
|
167
|
+
* missing: after this call the runtime's directive dispatch (applyDirectives /
|
|
168
|
+
* updateTraits) will invoke the bond solver for `@fixed_income_solver` orbs.
|
|
169
|
+
*/
|
|
170
|
+
export function registerBankingFinanceTraitHandlers(registrar: TraitRegistrar): void {
|
|
171
|
+
registerPluginTraits(registrar, BANKING_FINANCE_PLUGIN_ID, [fixedIncomeSolverHandler]);
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @account Trait — Financial account. @trait account */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type AccountType = 'checking' | 'savings' | 'credit' | 'investment' | 'loan' | 'escrow';
|
|
5
|
+
export interface AccountConfig { accountNumber: string; accountType: AccountType; currency: string; balance: number; interestRate: number; ownerKycId: string; }
|
|
6
|
+
export interface AccountState { currentBalance: number; availableBalance: number; pendingTransactions: number; isFrozen: boolean; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: AccountConfig = { accountNumber: '', accountType: 'checking', currency: 'USD', balance: 0, interestRate: 0, ownerKycId: '' };
|
|
9
|
+
|
|
10
|
+
export function createAccountHandler(): TraitHandler<AccountConfig> {
|
|
11
|
+
return { name: 'account', defaultConfig,
|
|
12
|
+
onAttach(n: HSPlusNode, c: AccountConfig, ctx: TraitContext) { n.__acctState = { currentBalance: c.balance, availableBalance: c.balance, pendingTransactions: 0, isFrozen: false }; ctx.emit?.('account:opened', { type: c.accountType }); },
|
|
13
|
+
onDetach(n: HSPlusNode, _c: AccountConfig, ctx: TraitContext) { delete n.__acctState; ctx.emit?.('account:closed'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(n: HSPlusNode, _c: AccountConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
+
const s = n.__acctState as AccountState | undefined; if (!s) return;
|
|
17
|
+
if (s.isFrozen) { ctx.emit?.('account:frozen_rejection'); return; }
|
|
18
|
+
if (e.type === 'account:credit') { const amt = e.payload?.amount as number; s.currentBalance += amt; s.availableBalance += amt; ctx.emit?.('account:credited', { amount: amt, balance: s.currentBalance }); }
|
|
19
|
+
if (e.type === 'account:debit') { const amt = e.payload?.amount as number; if (s.availableBalance >= amt) { s.currentBalance -= amt; s.availableBalance -= amt; ctx.emit?.('account:debited', { amount: amt, balance: s.currentBalance }); } else { ctx.emit?.('account:insufficient_funds', { requested: amt, available: s.availableBalance }); } }
|
|
20
|
+
if (e.type === 'account:freeze') { s.isFrozen = true; ctx.emit?.('account:frozen'); }
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** @kyc Trait — Know Your Customer verification. @trait kyc */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type KYCLevel = 'none' | 'basic' | 'enhanced' | 'full';
|
|
5
|
+
export type KYCStatus = 'pending' | 'verified' | 'rejected' | 'expired' | 'under_review';
|
|
6
|
+
export interface KYCConfig { level: KYCLevel; requiredDocuments: string[]; expiryDays: number; amlCheck: boolean; pepCheck: boolean; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: KYCConfig = { level: 'basic', requiredDocuments: ['government_id', 'proof_of_address'], expiryDays: 365, amlCheck: true, pepCheck: true };
|
|
9
|
+
|
|
10
|
+
export function createKYCHandler(): TraitHandler<KYCConfig> {
|
|
11
|
+
return { name: 'kyc', defaultConfig,
|
|
12
|
+
onAttach(n: HSPlusNode, _c: KYCConfig, ctx: TraitContext) { n.__kycState = { status: 'pending' as KYCStatus, documentsSubmitted: [] as string[], verifiedAt: null, riskScore: 0 }; ctx.emit?.('kyc:initiated'); },
|
|
13
|
+
onDetach(n: HSPlusNode, _c: KYCConfig, ctx: TraitContext) { delete n.__kycState; ctx.emit?.('kyc:removed'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(n: HSPlusNode, c: KYCConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
+
const s = n.__kycState as Record<string, unknown> | undefined; if (!s) return;
|
|
17
|
+
if (e.type === 'kyc:submit_document') { (s.documentsSubmitted as string[]).push(e.payload?.documentType as string); const allSubmitted = c.requiredDocuments.every(d => (s.documentsSubmitted as string[]).includes(d)); if (allSubmitted) { s.status = 'under_review'; ctx.emit?.('kyc:review_started'); } }
|
|
18
|
+
if (e.type === 'kyc:approve') { s.status = 'verified'; s.verifiedAt = Date.now(); ctx.emit?.('kyc:verified', { level: c.level }); }
|
|
19
|
+
if (e.type === 'kyc:reject') { s.status = 'rejected'; ctx.emit?.('kyc:rejected', { reason: e.payload?.reason }); }
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @portfolio Trait — Investment portfolio management. @trait portfolio */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface Holding { symbol: string; quantity: number; avgCostBasis: number; currentPrice: number; assetClass: 'equity' | 'bond' | 'crypto' | 'commodity' | 'real_estate' | 'cash'; }
|
|
5
|
+
export interface PortfolioConfig { holdings: Holding[]; benchmarkIndex: string; riskTolerance: 'conservative' | 'moderate' | 'aggressive'; rebalanceThreshold: number; }
|
|
6
|
+
export interface PortfolioState { totalValue: number; totalCost: number; unrealizedPnL: number; dayChange: number; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: PortfolioConfig = { holdings: [], benchmarkIndex: 'SPY', riskTolerance: 'moderate', rebalanceThreshold: 5 };
|
|
9
|
+
|
|
10
|
+
export function createPortfolioHandler(): TraitHandler<PortfolioConfig> {
|
|
11
|
+
return { name: 'portfolio', defaultConfig,
|
|
12
|
+
onAttach(n: HSPlusNode, c: PortfolioConfig, ctx: TraitContext) {
|
|
13
|
+
const totalValue = c.holdings.reduce((s, h) => s + h.currentPrice * h.quantity, 0);
|
|
14
|
+
const totalCost = c.holdings.reduce((s, h) => s + h.avgCostBasis * h.quantity, 0);
|
|
15
|
+
n.__portfolioState = { totalValue, totalCost, unrealizedPnL: totalValue - totalCost, dayChange: 0 };
|
|
16
|
+
ctx.emit?.('portfolio:loaded', { holdings: c.holdings.length, totalValue });
|
|
17
|
+
},
|
|
18
|
+
onDetach(n: HSPlusNode, _c: PortfolioConfig, ctx: TraitContext) { delete n.__portfolioState; ctx.emit?.('portfolio:closed'); },
|
|
19
|
+
onUpdate() {},
|
|
20
|
+
onEvent(n: HSPlusNode, c: PortfolioConfig, ctx: TraitContext, e: TraitEvent) {
|
|
21
|
+
const s = n.__portfolioState as PortfolioState | undefined; if (!s) return;
|
|
22
|
+
if (e.type === 'portfolio:price_update') {
|
|
23
|
+
const sym = e.payload?.symbol as string; const price = e.payload?.price as number;
|
|
24
|
+
const holding = c.holdings.find(h => h.symbol === sym);
|
|
25
|
+
if (holding) { holding.currentPrice = price; s.totalValue = c.holdings.reduce((sum, h) => sum + h.currentPrice * h.quantity, 0); s.unrealizedPnL = s.totalValue - s.totalCost; ctx.emit?.('portfolio:updated', { totalValue: s.totalValue, pnl: s.unrealizedPnL }); }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** @risk_model Trait — Financial risk assessment. @trait risk_model */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type RiskCategory = 'market' | 'credit' | 'operational' | 'liquidity' | 'regulatory';
|
|
5
|
+
export interface RiskModelConfig { category: RiskCategory; varConfidenceLevel: number; timeHorizonDays: number; stressScenarios: string[]; maxExposure: number; }
|
|
6
|
+
export interface RiskModelState { currentVaR: number; stressTestResults: Record<string, number>; riskScore: number; breachCount: number; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: RiskModelConfig = { category: 'market', varConfidenceLevel: 0.99, timeHorizonDays: 1, stressScenarios: ['crash_2008', 'covid_2020', 'rate_hike'], maxExposure: 1000000 };
|
|
9
|
+
|
|
10
|
+
export function createRiskModelHandler(): TraitHandler<RiskModelConfig> {
|
|
11
|
+
return { name: 'risk_model', defaultConfig,
|
|
12
|
+
onAttach(n: HSPlusNode, _c: RiskModelConfig, ctx: TraitContext) { n.__riskState = { currentVaR: 0, stressTestResults: {}, riskScore: 0, breachCount: 0 }; ctx.emit?.('risk:model_loaded'); },
|
|
13
|
+
onDetach(n: HSPlusNode, _c: RiskModelConfig, ctx: TraitContext) { delete n.__riskState; ctx.emit?.('risk:model_removed'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(n: HSPlusNode, c: RiskModelConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
+
const s = n.__riskState as RiskModelState | undefined; if (!s) return;
|
|
17
|
+
if (e.type === 'risk:calculate_var') { s.currentVaR = (e.payload?.portfolioValue as number ?? 0) * (1 - c.varConfidenceLevel) * Math.sqrt(c.timeHorizonDays); ctx.emit?.('risk:var_calculated', { var: s.currentVaR }); if (s.currentVaR > c.maxExposure) { s.breachCount++; ctx.emit?.('risk:breach', { var: s.currentVaR, limit: c.maxExposure }); } }
|
|
18
|
+
if (e.type === 'risk:stress_test') { for (const scenario of c.stressScenarios) { s.stressTestResults[scenario] = Math.random() * c.maxExposure * 0.3; } ctx.emit?.('risk:stress_complete', { results: s.stressTestResults }); }
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @transaction Trait — Financial transaction record. @trait transaction */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type TransactionType = 'deposit' | 'withdrawal' | 'transfer' | 'payment' | 'fee' | 'interest' | 'refund';
|
|
5
|
+
export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'reversed' | 'held';
|
|
6
|
+
export interface TransactionConfig { type: TransactionType; amount: number; currency: string; fromAccount: string; toAccount: string; description: string; reference: string; }
|
|
7
|
+
export interface TransactionState { status: TransactionStatus; timestamp: number; settledAt: number | null; }
|
|
8
|
+
|
|
9
|
+
const defaultConfig: TransactionConfig = { type: 'transfer', amount: 0, currency: 'USD', fromAccount: '', toAccount: '', description: '', reference: '' };
|
|
10
|
+
|
|
11
|
+
export function createTransactionHandler(): TraitHandler<TransactionConfig> {
|
|
12
|
+
return { name: 'transaction', defaultConfig,
|
|
13
|
+
onAttach(n: HSPlusNode, c: TransactionConfig, ctx: TraitContext) { n.__txnState = { status: 'pending' as TransactionStatus, timestamp: Date.now(), settledAt: null }; ctx.emit?.('transaction:created', { type: c.type, amount: c.amount }); },
|
|
14
|
+
onDetach(n: HSPlusNode, _c: TransactionConfig, ctx: TraitContext) { delete n.__txnState; ctx.emit?.('transaction:removed'); },
|
|
15
|
+
onUpdate() {},
|
|
16
|
+
onEvent(n: HSPlusNode, c: TransactionConfig, ctx: TraitContext, e: TraitEvent) {
|
|
17
|
+
const s = n.__txnState as TransactionState | undefined; if (!s) return;
|
|
18
|
+
if (e.type === 'transaction:settle') { s.status = 'completed'; s.settledAt = Date.now(); ctx.emit?.('transaction:settled', { amount: c.amount, reference: c.reference }); }
|
|
19
|
+
if (e.type === 'transaction:fail') { s.status = 'failed'; ctx.emit?.('transaction:failed', { reason: e.payload?.reason }); }
|
|
20
|
+
if (e.type === 'transaction:reverse') { s.status = 'reversed'; ctx.emit?.('transaction:reversed', { amount: c.amount }); }
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
|
|
2
|
+
export interface TraitContext { emit?: (event: string, payload?: unknown) => void; [key: string]: unknown; }
|
|
3
|
+
export interface TraitEvent { type: string; payload?: Record<string, unknown>; [key: string]: unknown; }
|
|
4
|
+
export interface TraitHandler<T = unknown> { name: string; defaultConfig: T; onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void; onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void; onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void; onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void; }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true }, "include": ["src"] }
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
// resolve.alias is REQUIRED: without it, `@holoscript/core/runtime` resolves to
|
|
5
|
+
// the STALE built dist and registerPluginTraits is missing ("not a function").
|
|
6
|
+
// Point the subpath imports at core/engine source so the runtime barrel
|
|
7
|
+
// (core/src/runtime.ts) and shared registrar resolve from source. Mirrors
|
|
8
|
+
// government-civic-plugin/vitest.config.ts.
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
resolve: {
|
|
11
|
+
alias: {
|
|
12
|
+
'@holoscript/engine': resolve(__dirname, '../../engine/src'),
|
|
13
|
+
'@holoscript/core': resolve(__dirname, '../../core/src'),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
test: {
|
|
17
|
+
globals: true,
|
|
18
|
+
environment: 'node',
|
|
19
|
+
include: ['src/**/*.test.ts'],
|
|
20
|
+
passWithNoTests: true,
|
|
21
|
+
},
|
|
22
|
+
});
|