Skip to content

Commit e68c211

Browse files
heyitsStylezclaude
andcommitted
Extract applyImportedTrades into 18b-chain-apply.js
Moves dedup-by-txHash, open/close split, close-trade matching, and OPEN→EXPIRED outcome correction out of syncRysk/syncHypersurface into two pure, dual-exported functions in a new module: applyCloseTrade(tradesArray, closeTrade) → boolean applyImportedTrades(tradesArray, openTrades, closeTrades, synced) → { added, closedCount, corrected } Outcome correction now runs across the full trades array on every sync, covering both RYSK and HSFC trades (previously each sync only corrected its own platform). syncRysk and syncHypersurface become thin wrappers: fetch → parse → applyImportedTrades → save/render if changed. Adds test/unit/chain-apply.test.js (10 pure-Node tests, no jsdom). Closes #59 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0afae73 commit e68c211

4 files changed

Lines changed: 175 additions & 76 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app.
6969
| `15-event-listeners.js` | 6 | global keydown (Esc closes modals/drawer) |
7070
| `16-clock.js` | 20 | UTC clock IIFE for header |
7171
| `17-boot.js` | 33 | init IIFE: load trades, wallet popup OR `render() + fetchExpiryPrices() + cloudPull → autoLoadChain` |
72-
| `18-chain-sync.js` | 579 | Rysk + Hypersurface chain sync: `autoLoadChain`, `syncRysk`, `syncHypersurface`, `resolveRyskOutcomes`, `resolveHsfcOutcomes`, `fetchRyskExpiryPrices`, `applyCloseTrade`, `autoDetectOutcomes` (stale-detection only), `migrateCloseTrades`. Routes through `/api/chain-sync` proxy. `hasProxy()` returns false on `file://` |
72+
| `18-chain-sync.js` | ~530 | Rysk + Hypersurface chain sync: `autoLoadChain`, `syncRysk`, `syncHypersurface`, `resolveRyskOutcomes`, `resolveHsfcOutcomes`, `fetchRyskExpiryPrices`, `autoDetectOutcomes` (stale-detection only), `migrateCloseTrades`. Routes through `/api/chain-sync` proxy. `hasProxy()` returns false on `file://` |
73+
| `18b-chain-apply.js` | ~55 | `applyCloseTrade(tradesArray, closeTrade)` → boolean; `applyImportedTrades(tradesArray, openTrades, closeTrades, synced)``{added, closedCount, corrected}`. Pure helpers extracted from chain-sync: dedup by txHash, open/close split, close-trade matching, OPEN→EXPIRED correction. Both dual-exported for Node tests |
7374

7475
**Line numbers above are approximate** — they shift as the code evolves. Use them
7576
as starting anchors, not exact addresses. Re-grep if a function moved.
@@ -116,7 +117,7 @@ typecheck step — plain JS, no TS.
116117
`getContext`, `scrollIntoView`. Returns `{ window, teardown }`; jsdom tests
117118
must call `t.after(teardown)` to release the clock interval.
118119
- **Dual-export pattern** (used by `02-utils.js`, `04b-lot-engine.js`,
119-
`05-compute.js`, `05a-merge-open-lots.js`): a guarded footer
120+
`05-compute.js`, `05a-merge-open-lots.js`, `18b-chain-apply.js`): a guarded footer
120121
`if (typeof module !== 'undefined' && module.exports) module.exports = {...}`.
121122
No-op in the browser; `require()`-able from Node tests. `build.py` does no
122123
stripping — the footer ships into `hyperwheel.html` and is harmless.

src/js/18-chain-sync.js

Lines changed: 11 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -164,37 +164,11 @@ async function syncRysk(address) {
164164
throw e;
165165
}
166166

167-
const synced = loadSynced();
168-
const newTrades = [];
169-
let corrected = 0;
170-
171-
for (const r of (positions || [])) {
172-
if (r.txHash && synced.has(r.txHash)) {
173-
// Already imported — correct OPEN→EXPIRED if now past expiry.
174-
// resolveRyskOutcomes will then set the definitive ASSIGNED/CALLED/EXPIRED.
175-
const nowTs = Math.floor(Date.now() / 1000);
176-
const expiryTs = r.expiry || 0;
177-
if (expiryTs > 0 && expiryTs < nowTs) {
178-
const existing = trades.find(t => t.txHash === r.txHash);
179-
if (existing && existing.outcome === 'OPEN') {
180-
existing.outcome = 'EXPIRED';
181-
corrected++;
182-
}
183-
}
184-
continue;
185-
}
186-
const t = parseRyskTrade(r);
187-
if (!t) continue;
188-
newTrades.push(t);
189-
if (r.txHash) synced.add(r.txHash);
190-
}
191-
192-
// Split: open trades imported first, then close trades applied against them
193-
const openTrades = newTrades.filter(t => !t.isClose);
194-
const closeTrades = newTrades.filter(t => t.isClose);
195-
let closedCount = 0;
196-
openTrades.forEach(t => trades.push(t));
197-
closeTrades.forEach(t => { if (applyCloseTrade(t)) closedCount++; });
167+
const synced = loadSynced();
168+
const allTrades = (positions || []).map(parseRyskTrade).filter(Boolean);
169+
const openTrades = allTrades.filter(t => !t.isClose);
170+
const closeTrades = allTrades.filter(t => t.isClose);
171+
const { added, closedCount, corrected } = applyImportedTrades(trades, openTrades, closeTrades, synced);
198172

199173
// Resolve outcomes from Rysk oracle settlement prices (authoritative — no CoinGecko needed).
200174
let posOutcomeChanged = false;
@@ -204,13 +178,13 @@ async function syncRysk(address) {
204178
// expiry-prices query failed — outcomes stay as EXPIRED; retry on next sync
205179
}
206180

207-
if (openTrades.length > 0 || closedCount > 0 || corrected > 0 || posOutcomeChanged) {
181+
if (added + closedCount + corrected > 0 || posOutcomeChanged) {
208182
save();
209183
render();
210184
saveSynced(synced);
211185
}
212186

213-
return { imported: newTrades.length, corrected, skipped: (positions || []).length - newTrades.length - corrected };
187+
return { imported: added, corrected, skipped: (positions || []).length - allTrades.length };
214188
}
215189

216190
// ── HYPERSURFACE SYNC ─────────────────────────────────────
@@ -363,38 +337,19 @@ async function syncHypersurface(address) {
363337

364338
const synced = loadSynced();
365339
const newTrades = [];
366-
let corrected = 0;
367340

368341
// Each trade can have multiple legs; each leg becomes one trade entry
369342
for (const trade of trades_raw) {
370343
for (const leg of (trade.legs || [])) {
371-
const key = (trade.createdTransaction || trade.id || '') + '-' + (leg.id || '');
372-
if (key && synced.has(key)) {
373-
// Already imported — correct OPEN→EXPIRED if now past expiry
374-
const oToken = leg.oToken;
375-
const expiryTs = oToken ? parseInt(oToken.expiryTimestamp || '0') : 0;
376-
const nowTs = Math.floor(Date.now() / 1000);
377-
if (expiryTs > 0 && expiryTs < nowTs) {
378-
const existing = trades.find(t => t.txHash === key);
379-
if (existing && existing.outcome === 'OPEN') {
380-
existing.outcome = 'EXPIRED';
381-
corrected++;
382-
}
383-
}
384-
continue;
385-
}
386344
const t = parseHsfcLeg(trade, leg);
387345
if (!t) continue;
388346
newTrades.push(t);
389-
if (key) synced.add(key);
390347
}
391348
}
392349

393350
const openTrades = newTrades.filter(t => !t.isClose);
394351
const closeTrades = newTrades.filter(t => t.isClose);
395-
let closedCount = 0;
396-
openTrades.forEach(t => trades.push(t));
397-
closeTrades.forEach(t => { if (applyCloseTrade(t)) closedCount++; });
352+
const { added, closedCount, corrected } = applyImportedTrades(trades, openTrades, closeTrades, synced);
398353

399354
// Resolve HSFC outcomes from on-chain Position data (authoritative — no CoinGecko needed).
400355
// positions(account, amount_lt:"0") = short positions (options the user sold).
@@ -412,35 +367,17 @@ async function syncHypersurface(address) {
412367
// positions query failed — outcomes stay as EXPIRED; will retry on next sync
413368
}
414369

415-
if (openTrades.length > 0 || closedCount > 0 || corrected > 0 || posOutcomeChanged) {
370+
if (added + closedCount + corrected > 0 || posOutcomeChanged) {
416371
save();
417372
render();
418373
saveSynced(synced);
419374
}
420375

421376
const totalLegs = trades_raw.reduce((n, t) => n + (t.legs || []).length, 0);
422-
return { imported: openTrades.length, closed: closedCount, skipped: totalLegs - newTrades.length };
423-
}
424-
425-
// ── CLOSE TRADE HANDLING ──────────────────────────────────
426-
// When a user buys back an option they sold, it arrives as a separate
427-
// trade entry with isClose=true. Instead of importing it as a row,
428-
// find the matching open trade and mark it CLOSED with closeCost set.
429-
430-
function applyCloseTrade(closeTrade) {
431-
const match = trades.find(t =>
432-
t.asset === closeTrade.asset &&
433-
t.type === closeTrade.type &&
434-
t.expiry === closeTrade.expiry &&
435-
Math.abs(t.strike - closeTrade.strike) < 0.01 &&
436-
t.outcome === 'OPEN'
437-
);
438-
if (!match) return false;
439-
match.outcome = 'CLOSED';
440-
match.closeCost = Math.abs(closeTrade.premium);
441-
return true;
377+
return { imported: added, closed: closedCount, skipped: totalLegs - newTrades.length };
442378
}
443379

380+
// ── MIGRATION ─────────────────────────────────────────────
444381
// Migration: clean up already-imported negative-premium OPEN trades
445382
// (synced before this fix was deployed).
446383
function migrateCloseTrades() {

src/js/18b-chain-apply.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// ── CHAIN APPLY ───────────────────────────────────────────────────────────────
2+
// Pure helpers for importing chain trades into the local trades array.
3+
// Parameterised on `tradesArray` rather than the global `trades` — safe to
4+
// require() from Node tests with no browser context.
5+
6+
function applyCloseTrade(tradesArray, closeTrade) {
7+
const match = tradesArray.find(t =>
8+
t.asset === closeTrade.asset &&
9+
t.type === closeTrade.type &&
10+
t.expiry === closeTrade.expiry &&
11+
Math.abs(t.strike - closeTrade.strike) < 0.01 &&
12+
t.outcome === 'OPEN'
13+
);
14+
if (!match) return false;
15+
match.outcome = 'CLOSED';
16+
match.closeCost = Math.abs(closeTrade.premium);
17+
return true;
18+
}
19+
20+
// Apply a batch of pre-parsed chain trades to tradesArray.
21+
// openTrades / closeTrades are split by the caller; synced Set is mutated
22+
// in-place. Returns { added, closedCount, corrected }.
23+
function applyImportedTrades(tradesArray, openTrades, closeTrades, synced) {
24+
let added = 0, closedCount = 0, corrected = 0;
25+
26+
for (const t of openTrades) {
27+
if (t.txHash && synced.has(t.txHash)) continue;
28+
const trade = Object.assign({}, t);
29+
delete trade.isClose;
30+
tradesArray.push(trade);
31+
if (t.txHash) synced.add(t.txHash);
32+
added++;
33+
}
34+
35+
for (const t of closeTrades) {
36+
if (t.txHash && synced.has(t.txHash)) continue;
37+
if (applyCloseTrade(tradesArray, t)) closedCount++;
38+
if (t.txHash) synced.add(t.txHash);
39+
}
40+
41+
// Correct any OPEN trade whose expiry date is now in the past — covers
42+
// pre-existing stale trades as well as newly added ones.
43+
const todayStr = new Date().toISOString().split('T')[0];
44+
for (const t of tradesArray) {
45+
if (t.outcome === 'OPEN' && t.expiry && t.expiry < todayStr) {
46+
t.outcome = 'EXPIRED';
47+
corrected++;
48+
}
49+
}
50+
51+
return { added, closedCount, corrected };
52+
}
53+
54+
if (typeof module !== 'undefined' && module.exports) {
55+
module.exports = { applyCloseTrade, applyImportedTrades };
56+
}

test/unit/chain-apply.test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert');
3+
const { applyCloseTrade, applyImportedTrades } = require('../../src/js/18b-chain-apply.js');
4+
5+
const PAST_DATE = '2020-01-01';
6+
const FUTURE_DATE = '2099-12-31';
7+
8+
// ── applyCloseTrade ──────────────────────────────────────────────────────────
9+
10+
test('applyCloseTrade: matches OPEN trade and sets CLOSED + closeCost', () => {
11+
const arr = [
12+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' },
13+
];
14+
const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3 });
15+
assert.strictEqual(result, true);
16+
assert.strictEqual(arr[0].outcome, 'CLOSED');
17+
assert.strictEqual(arr[0].closeCost, 3);
18+
});
19+
20+
test('applyCloseTrade: no match on strike → returns false, no mutation', () => {
21+
const arr = [
22+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' },
23+
];
24+
const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 35, premium: 3 });
25+
assert.strictEqual(result, false);
26+
assert.strictEqual(arr[0].outcome, 'OPEN');
27+
});
28+
29+
test('applyCloseTrade: non-OPEN trade is not matched', () => {
30+
const arr = [
31+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'EXPIRED' },
32+
];
33+
const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3 });
34+
assert.strictEqual(result, false);
35+
});
36+
37+
// ── applyImportedTrades ──────────────────────────────────────────────────────
38+
39+
test('applyImportedTrades: pushes open trade and strips isClose field', () => {
40+
const arr = [];
41+
const synced = new Set();
42+
const open = [{ id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', isClose: false, txHash: 'tx1' }];
43+
const { added } = applyImportedTrades(arr, open, [], synced);
44+
assert.strictEqual(added, 1);
45+
assert.strictEqual(arr.length, 1);
46+
assert.strictEqual('isClose' in arr[0], false, 'isClose must be stripped from pushed trade');
47+
assert.ok(synced.has('tx1'));
48+
});
49+
50+
test('applyImportedTrades: already-synced txHash is skipped', () => {
51+
const arr = [];
52+
const synced = new Set(['tx1']);
53+
const open = [{ id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', isClose: false, txHash: 'tx1' }];
54+
const { added } = applyImportedTrades(arr, open, [], synced);
55+
assert.strictEqual(added, 0);
56+
assert.strictEqual(arr.length, 0);
57+
});
58+
59+
test('applyImportedTrades: close trade matches open → CLOSED, closedCount: 1', () => {
60+
const arr = [
61+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' },
62+
];
63+
const synced = new Set();
64+
const close = [{ asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3, isClose: true, txHash: 'tx-c' }];
65+
const { closedCount } = applyImportedTrades(arr, [], close, synced);
66+
assert.strictEqual(closedCount, 1);
67+
assert.strictEqual(arr[0].outcome, 'CLOSED');
68+
assert.strictEqual(arr[0].closeCost, 3);
69+
assert.ok(synced.has('tx-c'));
70+
});
71+
72+
test('applyImportedTrades: unknown close trade with no match → closedCount: 0', () => {
73+
const arr = [];
74+
const synced = new Set();
75+
const close = [{ asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 99, premium: 3, isClose: true, txHash: 'tx-c' }];
76+
const { closedCount } = applyImportedTrades(arr, [], close, synced);
77+
assert.strictEqual(closedCount, 0);
78+
});
79+
80+
test('applyImportedTrades: OPEN RYSK trade with past expiry corrected to EXPIRED', () => {
81+
const arr = [
82+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: PAST_DATE, outcome: 'OPEN', platform: 'RYSK' },
83+
];
84+
const { corrected } = applyImportedTrades(arr, [], [], new Set());
85+
assert.strictEqual(corrected, 1);
86+
assert.strictEqual(arr[0].outcome, 'EXPIRED');
87+
});
88+
89+
test('applyImportedTrades: OPEN HSFC trade with past expiry corrected to EXPIRED', () => {
90+
const arr = [
91+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: PAST_DATE, outcome: 'OPEN', platform: 'HSFC' },
92+
];
93+
const { corrected } = applyImportedTrades(arr, [], [], new Set());
94+
assert.strictEqual(corrected, 1);
95+
assert.strictEqual(arr[0].outcome, 'EXPIRED');
96+
});
97+
98+
test('applyImportedTrades: OPEN trade with future expiry is not corrected', () => {
99+
const arr = [
100+
{ id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', platform: 'RYSK' },
101+
];
102+
const { corrected } = applyImportedTrades(arr, [], [], new Set());
103+
assert.strictEqual(corrected, 0);
104+
assert.strictEqual(arr[0].outcome, 'OPEN');
105+
});

0 commit comments

Comments
 (0)