@particle-academy/fancy-conformance 0.4.0 → 0.5.0

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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.4.0",
4
- "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI \u2014 so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
3
+ "version": "0.5.0",
4
+ "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Particle-Academy/fancy-conformance.git"
@@ -0,0 +1,364 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/trading-pnl",
4
+ "cases": [
5
+ {
6
+ "id": "0001-basis-fifo-worked-example",
7
+ "title": "fifo: buy 100@10, buy 100@20, sell 100@25, mark 25",
8
+ "since": "0.5.0",
9
+ "tags": [
10
+ "costBasis",
11
+ "fifo",
12
+ "headline"
13
+ ],
14
+ "fn": "realisedAndUnrealised",
15
+ "input": {
16
+ "basis": "fifo",
17
+ "contractType": "linear",
18
+ "multiplier": "1",
19
+ "moneyExp": 2,
20
+ "mark": "25.00",
21
+ "fills": [
22
+ {
23
+ "side": "buy",
24
+ "qty": "100",
25
+ "price": "10.00",
26
+ "at": 1
27
+ },
28
+ {
29
+ "side": "buy",
30
+ "qty": "100",
31
+ "price": "20.00",
32
+ "at": 2
33
+ },
34
+ {
35
+ "side": "sell",
36
+ "qty": "100",
37
+ "price": "25.00",
38
+ "at": 3
39
+ }
40
+ ]
41
+ },
42
+ "expected": {
43
+ "realised": "1500.00",
44
+ "unrealised": "500.00",
45
+ "qty": "100"
46
+ },
47
+ "notes": "Relieves the OLDEST lot: 100 @ $10 against $25 = $1,500 realised, leaving 100 @ $20. Total economic P&L is $2,000 under all three; only the realised/unrealised SPLIT differs, and that split is what tax, the blotter and a realised-P&L risk limit fire on."
48
+ },
49
+ {
50
+ "id": "0002-basis-average-worked-example",
51
+ "title": "average: buy 100@10, buy 100@20, sell 100@25, mark 25",
52
+ "since": "0.5.0",
53
+ "tags": [
54
+ "costBasis",
55
+ "average",
56
+ "headline"
57
+ ],
58
+ "fn": "realisedAndUnrealised",
59
+ "input": {
60
+ "basis": "average",
61
+ "contractType": "linear",
62
+ "multiplier": "1",
63
+ "moneyExp": 2,
64
+ "mark": "25.00",
65
+ "fills": [
66
+ {
67
+ "side": "buy",
68
+ "qty": "100",
69
+ "price": "10.00",
70
+ "at": 1
71
+ },
72
+ {
73
+ "side": "buy",
74
+ "qty": "100",
75
+ "price": "20.00",
76
+ "at": 2
77
+ },
78
+ {
79
+ "side": "sell",
80
+ "qty": "100",
81
+ "price": "25.00",
82
+ "at": 3
83
+ }
84
+ ]
85
+ },
86
+ "expected": {
87
+ "realised": "1000.00",
88
+ "unrealised": "1000.00",
89
+ "qty": "100"
90
+ },
91
+ "notes": "One basis at $15: 100 @ $15 against $25 = $1,000 realised, leaving 100 @ $15. Total economic P&L is $2,000 under all three; only the realised/unrealised SPLIT differs, and that split is what tax, the blotter and a realised-P&L risk limit fire on."
92
+ },
93
+ {
94
+ "id": "0003-basis-lifo-worked-example",
95
+ "title": "lifo: buy 100@10, buy 100@20, sell 100@25, mark 25",
96
+ "since": "0.5.0",
97
+ "tags": [
98
+ "costBasis",
99
+ "lifo",
100
+ "headline"
101
+ ],
102
+ "fn": "realisedAndUnrealised",
103
+ "input": {
104
+ "basis": "lifo",
105
+ "contractType": "linear",
106
+ "multiplier": "1",
107
+ "moneyExp": 2,
108
+ "mark": "25.00",
109
+ "fills": [
110
+ {
111
+ "side": "buy",
112
+ "qty": "100",
113
+ "price": "10.00",
114
+ "at": 1
115
+ },
116
+ {
117
+ "side": "buy",
118
+ "qty": "100",
119
+ "price": "20.00",
120
+ "at": 2
121
+ },
122
+ {
123
+ "side": "sell",
124
+ "qty": "100",
125
+ "price": "25.00",
126
+ "at": 3
127
+ }
128
+ ]
129
+ },
130
+ "expected": {
131
+ "realised": "500.00",
132
+ "unrealised": "1500.00",
133
+ "qty": "100"
134
+ },
135
+ "notes": "Relieves the NEWEST lot: 100 @ $20 against $25 = $500 realised, leaving 100 @ $10. Total economic P&L is $2,000 under all three; only the realised/unrealised SPLIT differs, and that split is what tax, the blotter and a realised-P&L risk limit fire on."
136
+ },
137
+ {
138
+ "id": "0004-flip-fifo",
139
+ "title": "fifo: buy 100@10 then sell 150@20 closes 100 and opens a NEW short of 50 at 20",
140
+ "since": "0.5.0",
141
+ "tags": [
142
+ "flip",
143
+ "fifo",
144
+ "hazard"
145
+ ],
146
+ "fn": "realisedAndUnrealised",
147
+ "input": {
148
+ "basis": "fifo",
149
+ "contractType": "linear",
150
+ "multiplier": "1",
151
+ "moneyExp": 2,
152
+ "mark": "20.00",
153
+ "fills": [
154
+ {
155
+ "side": "buy",
156
+ "qty": "100",
157
+ "price": "10.00",
158
+ "at": 1
159
+ },
160
+ {
161
+ "side": "sell",
162
+ "qty": "150",
163
+ "price": "20.00",
164
+ "at": 2
165
+ }
166
+ ]
167
+ },
168
+ "expected": {
169
+ "realised": "1000.00",
170
+ "unrealised": "0.00",
171
+ "qty": "-50"
172
+ },
173
+ "notes": "THE classic bug. The new short must be based at the SELL price, so at a mark of 20.00 it is flat (unrealised 0.00). An implementation that leaves it at the old 10.00 basis reports -500.00 here."
174
+ },
175
+ {
176
+ "id": "0005-flip-average",
177
+ "title": "average: buy 100@10 then sell 150@20 closes 100 and opens a NEW short of 50 at 20",
178
+ "since": "0.5.0",
179
+ "tags": [
180
+ "flip",
181
+ "average",
182
+ "hazard"
183
+ ],
184
+ "fn": "realisedAndUnrealised",
185
+ "input": {
186
+ "basis": "average",
187
+ "contractType": "linear",
188
+ "multiplier": "1",
189
+ "moneyExp": 2,
190
+ "mark": "20.00",
191
+ "fills": [
192
+ {
193
+ "side": "buy",
194
+ "qty": "100",
195
+ "price": "10.00",
196
+ "at": 1
197
+ },
198
+ {
199
+ "side": "sell",
200
+ "qty": "150",
201
+ "price": "20.00",
202
+ "at": 2
203
+ }
204
+ ]
205
+ },
206
+ "expected": {
207
+ "realised": "1000.00",
208
+ "unrealised": "0.00",
209
+ "qty": "-50"
210
+ },
211
+ "notes": "THE classic bug. The new short must be based at the SELL price, so at a mark of 20.00 it is flat (unrealised 0.00). An implementation that leaves it at the old 10.00 basis reports -500.00 here."
212
+ },
213
+ {
214
+ "id": "0006-flip-lifo",
215
+ "title": "lifo: buy 100@10 then sell 150@20 closes 100 and opens a NEW short of 50 at 20",
216
+ "since": "0.5.0",
217
+ "tags": [
218
+ "flip",
219
+ "lifo",
220
+ "hazard"
221
+ ],
222
+ "fn": "realisedAndUnrealised",
223
+ "input": {
224
+ "basis": "lifo",
225
+ "contractType": "linear",
226
+ "multiplier": "1",
227
+ "moneyExp": 2,
228
+ "mark": "20.00",
229
+ "fills": [
230
+ {
231
+ "side": "buy",
232
+ "qty": "100",
233
+ "price": "10.00",
234
+ "at": 1
235
+ },
236
+ {
237
+ "side": "sell",
238
+ "qty": "150",
239
+ "price": "20.00",
240
+ "at": 2
241
+ }
242
+ ]
243
+ },
244
+ "expected": {
245
+ "realised": "1000.00",
246
+ "unrealised": "0.00",
247
+ "qty": "-50"
248
+ },
249
+ "notes": "THE classic bug. The new short must be based at the SELL price, so at a mark of 20.00 it is flat (unrealised 0.00). An implementation that leaves it at the old 10.00 basis reports -500.00 here."
250
+ },
251
+ {
252
+ "id": "0007-inverse-up-5000",
253
+ "title": "inverse: 10000 contracts from 50000 to 55000",
254
+ "since": "0.5.0",
255
+ "tags": [
256
+ "inverse",
257
+ "nonlinear",
258
+ "headline"
259
+ ],
260
+ "fn": "closePnl",
261
+ "input": {
262
+ "contractType": "inverse",
263
+ "qty": "10000",
264
+ "entry": "50000",
265
+ "exit": "55000",
266
+ "multiplier": "1",
267
+ "moneyExp": 8
268
+ },
269
+ "expected": "0.01818182",
270
+ "notes": "Coin-margined: contracts * multiplier * (1/entry - 1/exit), settled in the BASE currency. Equal $5,000 moves up and down give UNEQUAL magnitudes -- that asymmetry is the assertion. A linear (exit-entry)*qty formula gives 50000000 here."
271
+ },
272
+ {
273
+ "id": "0008-inverse-down-5000",
274
+ "title": "inverse: 10000 contracts from 50000 to 45000",
275
+ "since": "0.5.0",
276
+ "tags": [
277
+ "inverse",
278
+ "nonlinear",
279
+ "headline"
280
+ ],
281
+ "fn": "closePnl",
282
+ "input": {
283
+ "contractType": "inverse",
284
+ "qty": "10000",
285
+ "entry": "50000",
286
+ "exit": "45000",
287
+ "multiplier": "1",
288
+ "moneyExp": 8
289
+ },
290
+ "expected": "-0.02222222",
291
+ "notes": "Coin-margined: contracts * multiplier * (1/entry - 1/exit), settled in the BASE currency. Equal $5,000 moves up and down give UNEQUAL magnitudes -- that asymmetry is the assertion. A linear (exit-entry)*qty formula gives 50000000 here."
292
+ },
293
+ {
294
+ "id": "0009-linear-control-same-inputs",
295
+ "title": "linear contract, same inputs -- the control that shows the formulas differ",
296
+ "since": "0.5.0",
297
+ "tags": [
298
+ "inverse",
299
+ "control"
300
+ ],
301
+ "fn": "closePnl",
302
+ "input": {
303
+ "contractType": "linear",
304
+ "qty": "10000",
305
+ "entry": "50000",
306
+ "exit": "55000",
307
+ "multiplier": "1",
308
+ "moneyExp": 8
309
+ },
310
+ "expected": "50000000.00000000",
311
+ "notes": "Same numbers, linear dispatch. If an implementation returns this value for the inverse rows, it is not dispatching on contractType at all."
312
+ },
313
+ {
314
+ "id": "0010-futures-multiplier",
315
+ "title": "ES: 2 contracts, 4 points, $50 multiplier = $400",
316
+ "since": "0.5.0",
317
+ "tags": [
318
+ "futures",
319
+ "multiplier"
320
+ ],
321
+ "fn": "closePnl",
322
+ "input": {
323
+ "contractType": "linear",
324
+ "qty": "2",
325
+ "entry": "4500.00",
326
+ "exit": "4504.00",
327
+ "multiplier": "50",
328
+ "moneyExp": 2
329
+ },
330
+ "expected": "400.00",
331
+ "notes": "tickValue = tickSize * multiplier is an invariant; a wrong multiplier silently corrupts every P&L and every risk-sized order."
332
+ },
333
+ {
334
+ "id": "0011-short-profits-as-price-falls",
335
+ "title": "short 10 @ 100, mark 90 -> +100 unrealised",
336
+ "since": "0.5.0",
337
+ "tags": [
338
+ "short"
339
+ ],
340
+ "fn": "realisedAndUnrealised",
341
+ "input": {
342
+ "basis": "average",
343
+ "contractType": "linear",
344
+ "multiplier": "1",
345
+ "moneyExp": 2,
346
+ "mark": "90.00",
347
+ "fills": [
348
+ {
349
+ "side": "sell",
350
+ "qty": "10",
351
+ "price": "100.00",
352
+ "at": 1
353
+ }
354
+ ]
355
+ },
356
+ "expected": {
357
+ "realised": "0.00",
358
+ "unrealised": "100.00",
359
+ "qty": "-10"
360
+ },
361
+ "notes": "Sign convention: a short position is a NEGATIVE quantity and profits as the mark falls."
362
+ }
363
+ ]
364
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/trading-pnl",
4
+ "title": "Position, cost basis and P&L across asset classes",
5
+ "since": "0.5.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "summary": "How a sequence of fills becomes a position, and how that position becomes realised and unrealised P&L. Three things here are wrong in most implementations and each has a row that fails against the naive version: realised P&L depends on the COST BASIS and the bases genuinely disagree; a position FLIP is a close plus an open at the new price; and INVERSE (coin-margined) contracts are non-linear in price, so one formula cannot serve both.",
10
+ "functions": {
11
+ "realisedAndUnrealised": "realisedAndUnrealised(basis, contractType, multiplier, moneyExp, fills[], mark) -> {realised, unrealised, qty} - apply the fills in order under the given cost basis, then value the remainder at the mark. All values are decimal STRINGS.",
12
+ "closePnl": "closePnl(contractType, qty, entry, exit, multiplier, moneyExp) -> decimal string - P&L of closing qty units opened at entry and closed at exit, for a LONG. Dispatches on contractType: linear is (exit-entry)*qty*multiplier; inverse is qty*multiplier*(1/entry - 1/exit) and settles in the BASE currency."
13
+ },
14
+ "reference": "node",
15
+ "referenceNote": "Goldens are produced by @particle-academy/fancy-trading, but the headline rows were derived by hand FIRST and the implementation checked against them, not the other way round: the three-way 1500/1000/500 split, the flip leaving a short based at the sell price, and the inverse asymmetry (10000*(1/50000-1/55000) = 0.01818181..., 10000*(1/50000-1/45000) = -0.02222222...) are all arithmetic anyone can verify without running this code. Rows tagged `control` exist to catch an implementation that produces a plausible answer by the wrong route.",
16
+ "implementations": [
17
+ {
18
+ "language": "node",
19
+ "package": "@particle-academy/fancy-trading",
20
+ "symbol": "position"
21
+ },
22
+ {
23
+ "language": "php",
24
+ "package": "particle-academy/fancy-trading",
25
+ "symbol": "ParticleAcademy\\Trading\\Position"
26
+ },
27
+ {
28
+ "language": "python",
29
+ "package": "fancy-trading",
30
+ "symbol": "fancy_trading.position"
31
+ }
32
+ ]
33
+ },
34
+ "normalisation": {
35
+ "money": "Every price, quantity and P&L in this table is a decimal STRING. There is not a single float in the fixture file, deliberately: a golden stored as a JSON number would be parsed through a double and could disagree with itself between runtimes before any implementation was involved.",
36
+ "sign": "Quantity is SIGNED -- positive long, negative short. P&L is signed from the position holder's perspective."
37
+ },
38
+ "notes": [
39
+ "The three worked-example rows (0001-0003) are the same fills under three bases. Total economic P&L is $2,000 in all three; only the realised/unrealised SPLIT differs. That split is what tax, the daily blotter and any realised-P&L risk limit fire on, so a realised number without its stated basis is meaningless.",
40
+ "The flip rows (0004-0006) assert unrealised 0.00 at a mark equal to the sell price. That is the cheap way to prove the new short is based at the SELL price rather than at the old average -- an implementation that carries the old basis forward reports -500.00 and nothing else in the table catches it.",
41
+ "0009 is a CONTROL: the same numbers as the inverse rows, dispatched as linear. An implementation returning 50000000 for 0007 is not dispatching on contractType at all.",
42
+ "Money-to-minor-unit conversion is NOT duplicated here -- it lives in shared/money-minor-units and is referenced rather than copied, because a golden that exists twice is a golden that can disagree with itself.",
43
+ "NOT COVERED, deliberately: fee treatment. Whether a fee lands in the cost basis or on its own line is a policy choice a host makes once, and both answers are defensible; a fixture table cannot arbitrate it. Each implementation asserts its own convention.",
44
+ "NOT COVERED: wash-sale and tax-lot selection rules. Those are jurisdictional, they change, and they belong to a tax engine rather than to a position engine."
45
+ ]
46
+ }