Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
85 changes: 11 additions & 74 deletions src/js/18-chain-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ─────────────────────────────────────
Expand Down Expand Up @@ -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).
Expand All @@ -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() {
Expand Down
56 changes: 56 additions & 0 deletions src/js/18b-chain-apply.js
Original file line number Diff line number Diff line change
@@ -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 };
}
105 changes: 105 additions & 0 deletions test/unit/chain-apply.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading