A sync whose positions read went out before the bot's own open or close finished no longer writes that account's positions back (ownOrderSeq, as claimSeq guards balances): the record a flatten goes by stays, so a read lagging the fill still gives FLATTEN FAILED, not "nothing to flatten". An open is noted in account_meta before its POST and dropped once its outcome is known. After a restart the first sync takes the note up as an open of unknown outcome, so the position it made is marked the bot's and guardClose sees it. Such an open is matched by the position id its answer named, else only to a position with no exit legs opened between the send and a few seconds past the error (was two minutes): an outside copy-trader's open half a minute on isn't taken for the bot's, and several matches adopt none, loudly. VestClient.tradeDate follows the last sync's numbers: past 8 PM ET the day turns once a sync has worked out the fresh day's realized P&L, so the engine, reporter and dashboard never pair the new day with the ended day's numbers. A stalled sync holds the day back only while isClientReady still trades on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
20 KiB
JavaScript
399 lines
20 KiB
JavaScript
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
|
// pure helpers show: the market-close guard closing a leg whose open came back
|
|
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
|
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
|
// trade beside an open of unknown outcome, and the client's day at 8 PM ET.
|
|
//
|
|
// npm run check
|
|
//
|
|
// A scratch database, a fake clock, and every gateway call answered here —
|
|
// nothing reaches Vest.
|
|
import { after, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
|
|
|
// Resolve the extensionless local imports used by Next, without a build.
|
|
function resolveTypes(specifier, context, nextResolve) {
|
|
try {
|
|
return nextResolve(specifier, context);
|
|
} catch (err) {
|
|
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
|
return nextResolve(`${specifier}.ts`, context);
|
|
}
|
|
}
|
|
// The client's classes use parameter properties, which plain type
|
|
// stripping refuses: transform them instead.
|
|
function loadTypes(url, context, nextLoad) {
|
|
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
|
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
|
return { format: 'module', source, shortCircuit: true };
|
|
}
|
|
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
|
|
|
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
|
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
|
delete process.env.NEXT_PHASE;
|
|
after(() => rmSync(directory, { recursive: true, force: true }));
|
|
|
|
// No request leaves this process: axios refuses every one, and each client's
|
|
// gateway is a function in this file — from construction on, since the
|
|
// constructor starts a sync.
|
|
const { default: axios } = await import('../lib/http-client.ts');
|
|
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
|
const { VestClient } = await import('../lib/vest-class.ts');
|
|
let gateway = null;
|
|
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
|
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
|
return gateway(method, path, opts);
|
|
};
|
|
const { putVestSession } = await import('../lib/vest-session.ts');
|
|
const { botOpenedPositions, loadAccountMeta, saveAccountMeta } = await import('../lib/db.ts');
|
|
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
|
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
|
const { tradeDateFor } = await import('../lib/trading-logic.ts');
|
|
const { isClientReady } = await import('../lib/platform-client.ts');
|
|
|
|
const iso = (s) => Date.parse(s);
|
|
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
|
|
|
// The clock the client reads (Date.now), set to an instant and running on.
|
|
const realNow = Date.now;
|
|
after(() => { Date.now = realNow; });
|
|
let tokens = 0;
|
|
function clockAt(when) {
|
|
const start = realNow();
|
|
const t0 = iso(when);
|
|
Date.now = () => t0 + (realNow() - start);
|
|
tokens += 1;
|
|
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
|
}
|
|
|
|
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
|
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
|
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
|
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
|
const week = [
|
|
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
|
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
|
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
|
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
|
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
|
];
|
|
const spec = (symbol, assetId) => ({
|
|
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
|
|
});
|
|
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
|
|
|
|
/** A client with no sync running, its gateway answered by `answer`, holding
|
|
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
|
|
function testClient(answer, ids = [1, 2]) {
|
|
gateway = null;
|
|
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
|
clearInterval(client.syncInterval);
|
|
clearInterval(client.watchdogInterval);
|
|
gateway = answer;
|
|
client.specs = specs;
|
|
client.symbolSpecs = async () => specs;
|
|
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
|
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
|
|
ids.forEach((id, i) => {
|
|
client.uuidById.set(id, `acc-${i + 1}`);
|
|
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
|
|
});
|
|
return client;
|
|
}
|
|
|
|
const A = vestAccountId('acc-1');
|
|
const B = vestAccountId('acc-2');
|
|
const NDX = encodeContractName('NDX-USD', 0.001);
|
|
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
|
|
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
|
stopLosses: [], takeProfits: [], ...extra,
|
|
});
|
|
|
|
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
|
|
* main account; `positions` open), and the history reads after it. */
|
|
function syncReads(method, path, positions) {
|
|
if (method !== 'GET') return null;
|
|
if (path === '/v3/capital/accounts/active') {
|
|
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
|
|
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
|
|
})) } };
|
|
}
|
|
if (path === '/v3/accounts') {
|
|
return { data: { accounts: [
|
|
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
|
] } };
|
|
}
|
|
if (path === '/v3/positions/opened') return { data: { positions } };
|
|
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
|
|
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
|
|
if (path === '/v3/positions') return { data: { positions: [] } };
|
|
return null;
|
|
}
|
|
|
|
/** testClient on the ids a sync gives, once the constructor's own sync
|
|
* (which reaches no gateway) is over, with its history taken as read — so
|
|
* each requestSync here is one good sync, and starts nothing else. */
|
|
async function syncingClient(answer) {
|
|
const client = testClient(answer, [A, B]);
|
|
await client.syncRunning;
|
|
client.historyLoaded = true;
|
|
client.historyLoadedIds = new Set([A, B]);
|
|
client.lastDaysFetchStartedAt = Date.now();
|
|
client.lastDaysFetchSucceededAt = Infinity;
|
|
return client;
|
|
}
|
|
|
|
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
|
|
|
|
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
|
let positions = [];
|
|
const closes = [];
|
|
const client = testClient((method, path, opts) => {
|
|
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
|
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
|
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
|
});
|
|
try {
|
|
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
|
clockAt('2026-10-30T12:30:00Z');
|
|
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
|
const raw = (positionId, accountId, openDate) => ({
|
|
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
|
stopLosses: [], takeProfits: [],
|
|
});
|
|
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
|
client.positionsDetail.set(1, []);
|
|
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
|
client.syncComplete = true;
|
|
|
|
// 4:56 PM ET: four minutes to the weekly close.
|
|
clockAt('2026-10-30T20:56:00Z');
|
|
await client.guardClose();
|
|
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
|
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
|
// A close just sent isn't sent again while a read may not show it.
|
|
await client.guardClose();
|
|
assert.equal(closes.length, 1);
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
});
|
|
|
|
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
|
const client = testClient((method, path) => {
|
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
|
});
|
|
client.refreshMarks = async () => {};
|
|
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
|
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
|
try {
|
|
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
|
// before the reset — no gate.
|
|
clockAt('2026-10-29T18:00:00Z');
|
|
marked();
|
|
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
|
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
|
assert.equal(thursday.closesAt, undefined);
|
|
|
|
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
|
clockAt('2026-10-30T18:00:00Z');
|
|
marked();
|
|
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
|
assert.equal(pick.id, 'BTC-PERP');
|
|
assert.equal(pick.closesAt, undefined);
|
|
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
|
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
|
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
|
// The survey keeps it up for the dashboard, though nothing is picked.
|
|
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
|
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
|
// And the client's day is Vest's.
|
|
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
});
|
|
|
|
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
|
|
let positions = [];
|
|
let holdRead = false;
|
|
let releaseRead = null;
|
|
clockAt('2026-10-28T14:00:00Z');
|
|
const client = await syncingClient((method, path) => {
|
|
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
|
|
// Read now, flat — answered once released.
|
|
holdRead = false;
|
|
const served = positions;
|
|
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
|
|
}
|
|
const read = syncReads(method, path, positions);
|
|
if (read) return read;
|
|
if (method === 'POST' && path === '/v3/positions/open') {
|
|
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
|
|
return { data: { orderId: 'o-9101', positionId: '9101' } };
|
|
}
|
|
return unanswered(method, path);
|
|
});
|
|
try {
|
|
await client.requestSync();
|
|
// A sync reads acc-1 flat; the entry fills while it is still out.
|
|
holdRead = true;
|
|
const sync = client.requestSync();
|
|
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
|
|
// Filled: the note kept against a restart goes.
|
|
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
|
releaseRead();
|
|
await sync;
|
|
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
|
|
assert.equal(client.positions[A].netPos, 0.5);
|
|
// A read lagging the fill: the leg's flatten is refused as a position
|
|
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
|
|
positions = [];
|
|
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
|
|
// A sync that went out after the fill writes what it reads.
|
|
await client.requestSync();
|
|
assert.deepEqual(client.positionsDetail.get(A), []);
|
|
assert.equal(client.positions[A], undefined);
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
});
|
|
|
|
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
|
|
let positions = [];
|
|
let posted;
|
|
const postedAt = new Promise((resolve) => { posted = resolve; });
|
|
clockAt('2026-10-28T15:00:00Z');
|
|
const before = await syncingClient((method, path) => {
|
|
const read = syncReads(method, path, positions);
|
|
if (read) return read;
|
|
// The app stops with the open out.
|
|
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
|
|
return unanswered(method, path);
|
|
});
|
|
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
|
|
const sentAt = await postedAt;
|
|
before.disconnect();
|
|
|
|
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
|
|
// an older note, of a short that never showed.
|
|
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
|
|
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
|
|
clockAt('2026-10-28T15:02:00Z');
|
|
let refuse = false;
|
|
const after = await syncingClient((method, path) => {
|
|
const read = syncReads(method, path, positions);
|
|
if (read) return read;
|
|
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
|
|
return unanswered(method, path);
|
|
});
|
|
try {
|
|
await after.requestSync();
|
|
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
|
|
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
|
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
|
|
// An open Vest refuses leaves no note behind.
|
|
positions = [];
|
|
refuse = true;
|
|
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
|
|
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
|
} finally {
|
|
after.disconnect();
|
|
}
|
|
});
|
|
|
|
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
|
|
let positions = [];
|
|
let open = null;
|
|
let onRead = () => {};
|
|
clockAt('2026-10-28T16:00:00Z');
|
|
const client = await syncingClient((method, path) => {
|
|
if (method === 'GET' && path === '/v3/positions/opened') onRead();
|
|
const read = syncReads(method, path, positions);
|
|
if (read) return read;
|
|
if (method === 'POST' && path === '/v3/positions/open') return open();
|
|
return unanswered(method, path);
|
|
});
|
|
try {
|
|
await client.requestSync();
|
|
// The entry's send fails in transit, and it never went in. Half a
|
|
// minute on, an outside copy-trader opens NDX long on every account.
|
|
open = () => { throw new Error('socket hang up'); };
|
|
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
|
const errorAt = Date.now();
|
|
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
|
|
clockAt('2026-10-28T16:01:00Z');
|
|
await client.requestSync();
|
|
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
|
|
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
|
|
|
// Another on acc-2, and two positions show from that moment: either
|
|
// could be the bot's, so neither is marked.
|
|
positions = [];
|
|
clockAt('2026-10-28T16:05:00Z');
|
|
open = () => {
|
|
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
|
|
throw new Error('socket hang up');
|
|
};
|
|
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
|
await client.requestSync();
|
|
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
|
|
|
|
// An open whose answer named its position, though no read showed it
|
|
// within the wait: that position is the bot's, even with a stop a
|
|
// hand has put on it since.
|
|
positions = [];
|
|
clockAt('2026-10-28T16:10:00Z');
|
|
let answered = false;
|
|
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
|
|
// The wait's first read comes back past its deadline.
|
|
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
|
|
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
|
|
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
|
|
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
|
|
await client.requestSync();
|
|
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
});
|
|
|
|
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
|
|
clockAt('2026-10-27T23:58:00Z');
|
|
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
|
|
// The day the engine plans the lane on, at this test's clock.
|
|
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
|
|
try {
|
|
// 7:58 PM ET, before any sync: the day now.
|
|
assert.equal(engineDay(), '2026-10-27');
|
|
await client.requestSync();
|
|
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
|
|
// worked out are still the ended day's — and so is the engine's day.
|
|
clockAt('2026-10-28T00:00:20Z');
|
|
assert.equal(vestDayOf(Date.now()), '2026-10-28');
|
|
assert.equal(engineDay(), '2026-10-27');
|
|
assert.equal(isClientReady(client), true);
|
|
await client.requestSync();
|
|
assert.equal(engineDay(), '2026-10-28');
|
|
// No sync after the next reset: the day holds back only while the
|
|
// scheduler still trades on the last sync's numbers.
|
|
clockAt('2026-10-28T23:59:00Z');
|
|
await client.requestSync();
|
|
clockAt('2026-10-29T00:03:00Z');
|
|
assert.equal(engineDay(), '2026-10-28');
|
|
assert.equal(isClientReady(client), true);
|
|
clockAt('2026-10-29T00:05:00Z');
|
|
assert.equal(engineDay(), '2026-10-28');
|
|
assert.equal(isClientReady(client), false);
|
|
} finally {
|
|
client.disconnect();
|
|
}
|
|
});
|