From e68c2115bf6d77ba9c53c6e5dd7059bf535cb8a3 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Sat, 16 May 2026 20:00:06 +0800 Subject: [PATCH] Extract applyImportedTrades into 18b-chain-apply.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 5 +- src/js/18-chain-sync.js | 85 ++++----------------------- src/js/18b-chain-apply.js | 56 ++++++++++++++++++ test/unit/chain-apply.test.js | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 76 deletions(-) create mode 100644 src/js/18b-chain-apply.js create mode 100644 test/unit/chain-apply.test.js 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'); +});