diff --git a/CLAUDE.md b/CLAUDE.md index 2f67c40..1f21cfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,8 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app. | `15-event-listeners.js` | 6 | global keydown (Esc closes modals/drawer) | | `16-clock.js` | 20 | UTC clock IIFE for header | | `17-boot.js` | 33 | init IIFE: load trades, wallet popup OR `render() + fetchExpiryPrices() + cloudPull → autoLoadChain` | -| `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://` | +| `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://` | +| `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 | **Line numbers above are approximate** — they shift as the code evolves. Use them as starting anchors, not exact addresses. Re-grep if a function moved. @@ -116,7 +117,7 @@ typecheck step — plain JS, no TS. `getContext`, `scrollIntoView`. Returns `{ window, teardown }`; jsdom tests must call `t.after(teardown)` to release the clock interval. - **Dual-export pattern** (used by `02-utils.js`, `04b-lot-engine.js`, - `05-compute.js`, `05a-merge-open-lots.js`): a guarded footer + `05-compute.js`, `05a-merge-open-lots.js`, `18b-chain-apply.js`): a guarded footer `if (typeof module !== 'undefined' && module.exports) module.exports = {...}`. No-op in the browser; `require()`-able from Node tests. `build.py` does no stripping — the footer ships into `hyperwheel.html` and is harmless. diff --git a/src/js/18-chain-sync.js b/src/js/18-chain-sync.js index b66069b..dfbc2fa 100644 --- a/src/js/18-chain-sync.js +++ b/src/js/18-chain-sync.js @@ -164,37 +164,11 @@ async function syncRysk(address) { throw e; } - const synced = loadSynced(); - const newTrades = []; - let corrected = 0; - - for (const r of (positions || [])) { - if (r.txHash && synced.has(r.txHash)) { - // Already imported — correct OPEN→EXPIRED if now past expiry. - // resolveRyskOutcomes will then set the definitive ASSIGNED/CALLED/EXPIRED. - const nowTs = Math.floor(Date.now() / 1000); - const expiryTs = r.expiry || 0; - if (expiryTs > 0 && expiryTs < nowTs) { - const existing = trades.find(t => t.txHash === r.txHash); - if (existing && existing.outcome === 'OPEN') { - existing.outcome = 'EXPIRED'; - corrected++; - } - } - continue; - } - const t = parseRyskTrade(r); - if (!t) continue; - newTrades.push(t); - if (r.txHash) synced.add(r.txHash); - } - - // Split: open trades imported first, then close trades applied against them - const openTrades = newTrades.filter(t => !t.isClose); - const closeTrades = newTrades.filter(t => t.isClose); - let closedCount = 0; - openTrades.forEach(t => trades.push(t)); - closeTrades.forEach(t => { if (applyCloseTrade(t)) closedCount++; }); + const synced = loadSynced(); + const allTrades = (positions || []).map(parseRyskTrade).filter(Boolean); + const openTrades = allTrades.filter(t => !t.isClose); + const closeTrades = allTrades.filter(t => t.isClose); + const { added, closedCount, corrected } = applyImportedTrades(trades, openTrades, closeTrades, synced); // Resolve outcomes from Rysk oracle settlement prices (authoritative — no CoinGecko needed). let posOutcomeChanged = false; @@ -204,13 +178,13 @@ async function syncRysk(address) { // expiry-prices query failed — outcomes stay as EXPIRED; retry on next sync } - if (openTrades.length > 0 || closedCount > 0 || corrected > 0 || posOutcomeChanged) { + if (added + closedCount + corrected > 0 || posOutcomeChanged) { save(); render(); saveSynced(synced); } - return { imported: newTrades.length, corrected, skipped: (positions || []).length - newTrades.length - corrected }; + return { imported: added, corrected, skipped: (positions || []).length - allTrades.length }; } // ── HYPERSURFACE SYNC ───────────────────────────────────── @@ -363,38 +337,19 @@ async function syncHypersurface(address) { const synced = loadSynced(); const newTrades = []; - let corrected = 0; // Each trade can have multiple legs; each leg becomes one trade entry for (const trade of trades_raw) { for (const leg of (trade.legs || [])) { - const key = (trade.createdTransaction || trade.id || '') + '-' + (leg.id || ''); - if (key && synced.has(key)) { - // Already imported — correct OPEN→EXPIRED if now past expiry - const oToken = leg.oToken; - const expiryTs = oToken ? parseInt(oToken.expiryTimestamp || '0') : 0; - const nowTs = Math.floor(Date.now() / 1000); - if (expiryTs > 0 && expiryTs < nowTs) { - const existing = trades.find(t => t.txHash === key); - if (existing && existing.outcome === 'OPEN') { - existing.outcome = 'EXPIRED'; - corrected++; - } - } - continue; - } const t = parseHsfcLeg(trade, leg); if (!t) continue; newTrades.push(t); - if (key) synced.add(key); } } const openTrades = newTrades.filter(t => !t.isClose); const closeTrades = newTrades.filter(t => t.isClose); - let closedCount = 0; - openTrades.forEach(t => trades.push(t)); - closeTrades.forEach(t => { if (applyCloseTrade(t)) closedCount++; }); + const { added, closedCount, corrected } = applyImportedTrades(trades, openTrades, closeTrades, synced); // Resolve HSFC outcomes from on-chain Position data (authoritative — no CoinGecko needed). // positions(account, amount_lt:"0") = short positions (options the user sold). @@ -412,35 +367,17 @@ async function syncHypersurface(address) { // positions query failed — outcomes stay as EXPIRED; will retry on next sync } - if (openTrades.length > 0 || closedCount > 0 || corrected > 0 || posOutcomeChanged) { + if (added + closedCount + corrected > 0 || posOutcomeChanged) { save(); render(); saveSynced(synced); } const totalLegs = trades_raw.reduce((n, t) => n + (t.legs || []).length, 0); - return { imported: openTrades.length, closed: closedCount, skipped: totalLegs - newTrades.length }; -} - -// ── CLOSE TRADE HANDLING ────────────────────────────────── -// When a user buys back an option they sold, it arrives as a separate -// trade entry with isClose=true. Instead of importing it as a row, -// find the matching open trade and mark it CLOSED with closeCost set. - -function applyCloseTrade(closeTrade) { - const match = trades.find(t => - t.asset === closeTrade.asset && - t.type === closeTrade.type && - t.expiry === closeTrade.expiry && - Math.abs(t.strike - closeTrade.strike) < 0.01 && - t.outcome === 'OPEN' - ); - if (!match) return false; - match.outcome = 'CLOSED'; - match.closeCost = Math.abs(closeTrade.premium); - return true; + return { imported: added, closed: closedCount, skipped: totalLegs - newTrades.length }; } +// ── MIGRATION ───────────────────────────────────────────── // Migration: clean up already-imported negative-premium OPEN trades // (synced before this fix was deployed). function migrateCloseTrades() { diff --git a/src/js/18b-chain-apply.js b/src/js/18b-chain-apply.js new file mode 100644 index 0000000..19b6418 --- /dev/null +++ b/src/js/18b-chain-apply.js @@ -0,0 +1,56 @@ +// ── CHAIN APPLY ─────────────────────────────────────────────────────────────── +// Pure helpers for importing chain trades into the local trades array. +// Parameterised on `tradesArray` rather than the global `trades` — safe to +// require() from Node tests with no browser context. + +function applyCloseTrade(tradesArray, closeTrade) { + const match = tradesArray.find(t => + t.asset === closeTrade.asset && + t.type === closeTrade.type && + t.expiry === closeTrade.expiry && + Math.abs(t.strike - closeTrade.strike) < 0.01 && + t.outcome === 'OPEN' + ); + if (!match) return false; + match.outcome = 'CLOSED'; + match.closeCost = Math.abs(closeTrade.premium); + return true; +} + +// Apply a batch of pre-parsed chain trades to tradesArray. +// openTrades / closeTrades are split by the caller; synced Set is mutated +// in-place. Returns { added, closedCount, corrected }. +function applyImportedTrades(tradesArray, openTrades, closeTrades, synced) { + let added = 0, closedCount = 0, corrected = 0; + + for (const t of openTrades) { + if (t.txHash && synced.has(t.txHash)) continue; + const trade = Object.assign({}, t); + delete trade.isClose; + tradesArray.push(trade); + if (t.txHash) synced.add(t.txHash); + added++; + } + + for (const t of closeTrades) { + if (t.txHash && synced.has(t.txHash)) continue; + if (applyCloseTrade(tradesArray, t)) closedCount++; + if (t.txHash) synced.add(t.txHash); + } + + // Correct any OPEN trade whose expiry date is now in the past — covers + // pre-existing stale trades as well as newly added ones. + const todayStr = new Date().toISOString().split('T')[0]; + for (const t of tradesArray) { + if (t.outcome === 'OPEN' && t.expiry && t.expiry < todayStr) { + t.outcome = 'EXPIRED'; + corrected++; + } + } + + return { added, closedCount, corrected }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { applyCloseTrade, applyImportedTrades }; +} diff --git a/test/unit/chain-apply.test.js b/test/unit/chain-apply.test.js new file mode 100644 index 0000000..4895fea --- /dev/null +++ b/test/unit/chain-apply.test.js @@ -0,0 +1,105 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { applyCloseTrade, applyImportedTrades } = require('../../src/js/18b-chain-apply.js'); + +const PAST_DATE = '2020-01-01'; +const FUTURE_DATE = '2099-12-31'; + +// ── applyCloseTrade ────────────────────────────────────────────────────────── + +test('applyCloseTrade: matches OPEN trade and sets CLOSED + closeCost', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' }, + ]; + const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3 }); + assert.strictEqual(result, true); + assert.strictEqual(arr[0].outcome, 'CLOSED'); + assert.strictEqual(arr[0].closeCost, 3); +}); + +test('applyCloseTrade: no match on strike → returns false, no mutation', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' }, + ]; + const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 35, premium: 3 }); + assert.strictEqual(result, false); + assert.strictEqual(arr[0].outcome, 'OPEN'); +}); + +test('applyCloseTrade: non-OPEN trade is not matched', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'EXPIRED' }, + ]; + const result = applyCloseTrade(arr, { asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3 }); + assert.strictEqual(result, false); +}); + +// ── applyImportedTrades ────────────────────────────────────────────────────── + +test('applyImportedTrades: pushes open trade and strips isClose field', () => { + const arr = []; + const synced = new Set(); + const open = [{ id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', isClose: false, txHash: 'tx1' }]; + const { added } = applyImportedTrades(arr, open, [], synced); + assert.strictEqual(added, 1); + assert.strictEqual(arr.length, 1); + assert.strictEqual('isClose' in arr[0], false, 'isClose must be stripped from pushed trade'); + assert.ok(synced.has('tx1')); +}); + +test('applyImportedTrades: already-synced txHash is skipped', () => { + const arr = []; + const synced = new Set(['tx1']); + const open = [{ id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', isClose: false, txHash: 'tx1' }]; + const { added } = applyImportedTrades(arr, open, [], synced); + assert.strictEqual(added, 0); + assert.strictEqual(arr.length, 0); +}); + +test('applyImportedTrades: close trade matches open → CLOSED, closedCount: 1', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 10, outcome: 'OPEN' }, + ]; + const synced = new Set(); + const close = [{ asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 30, premium: 3, isClose: true, txHash: 'tx-c' }]; + const { closedCount } = applyImportedTrades(arr, [], close, synced); + assert.strictEqual(closedCount, 1); + assert.strictEqual(arr[0].outcome, 'CLOSED'); + assert.strictEqual(arr[0].closeCost, 3); + assert.ok(synced.has('tx-c')); +}); + +test('applyImportedTrades: unknown close trade with no match → closedCount: 0', () => { + const arr = []; + const synced = new Set(); + const close = [{ asset: 'HYPE', type: 'PUT', expiry: '2026-05-01', strike: 99, premium: 3, isClose: true, txHash: 'tx-c' }]; + const { closedCount } = applyImportedTrades(arr, [], close, synced); + assert.strictEqual(closedCount, 0); +}); + +test('applyImportedTrades: OPEN RYSK trade with past expiry corrected to EXPIRED', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: PAST_DATE, outcome: 'OPEN', platform: 'RYSK' }, + ]; + const { corrected } = applyImportedTrades(arr, [], [], new Set()); + assert.strictEqual(corrected, 1); + assert.strictEqual(arr[0].outcome, 'EXPIRED'); +}); + +test('applyImportedTrades: OPEN HSFC trade with past expiry corrected to EXPIRED', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: PAST_DATE, outcome: 'OPEN', platform: 'HSFC' }, + ]; + const { corrected } = applyImportedTrades(arr, [], [], new Set()); + assert.strictEqual(corrected, 1); + assert.strictEqual(arr[0].outcome, 'EXPIRED'); +}); + +test('applyImportedTrades: OPEN trade with future expiry is not corrected', () => { + const arr = [ + { id: 1, asset: 'HYPE', type: 'PUT', expiry: FUTURE_DATE, outcome: 'OPEN', platform: 'RYSK' }, + ]; + const { corrected } = applyImportedTrades(arr, [], [], new Set()); + assert.strictEqual(corrected, 0); + assert.strictEqual(arr[0].outcome, 'OPEN'); +});