Back to OScope
oscope/public/js/paper.js
Code
95 lines (83 loc) · 4.12 KB
// Paper trading: a wallet that lives in this browser. Fills use the pool's reserves at the moment of the click
// (constant product, the pool's fee included), so size moves the price the way it would on chain.
import { store, emitter, toast, sol as fsol } from './util.js';
import { live, net } from './net.js';
const KEY = 'paper';
const fresh = start => ({ start, sol: start, pos: {}, hist: [], realized: 0, created: Date.now() });
export const paper = Object.assign(emitter(), {
s: store.get(KEY, null) || fresh(10),
px: {}, // mint -> { price, mc, pool, rq, rt, fee, n, s, img }
save() { store.set(KEY, this.s); this.emit('change'); this.syncWatch(); },
reset(start = this.s.start) { this.s = fresh(+start > 0 ? +start : 10); this.save(); },
syncWatch() { net.watch(Object.keys(this.s.pos)); },
// a coin's pool as the page last saw it
learn(m, q) { if (q && q.rq > 0 && q.rt > 0) this.px[m] = { ...this.px[m], ...q }; },
quoteBuy(q, solIn) {
const solNet = solIn / (1 + q.fee);
const k = q.rq * q.rt;
return q.rt - k / (q.rq + solNet);
},
quoteSell(q, tok) {
const k = q.rq * q.rt;
const gross = q.rq - k / (q.rt + tok);
return gross * (1 - q.fee);
},
buy(m, solIn, info = {}) {
const q = this.px[m];
solIn = +solIn;
if (!q) return toast('No price for this coin yet', 'warn');
if (!(solIn > 0)) return toast('Enter an amount', 'warn');
if (solIn > this.s.sol + 1e-9) return toast(`Not enough SOL: ${fsol(this.s.sol)} in the paper wallet`, 'warn');
const tok = this.quoteBuy(q, solIn);
if (!(tok > 0)) return toast('This pool cannot fill that', 'warn');
const p = this.s.pos[m] ||= { tok: 0, cost: 0, spent: 0, got: 0, realized: 0, n: info.n || q.n || '', s: info.s || q.s || '', img: info.img || q.img || '', first: Date.now() };
p.tok += tok; p.cost += solIn; p.spent += solIn; p.last = Date.now();
if (info.n) p.n = info.n; if (info.s) p.s = info.s; if (info.img) p.img = info.img;
this.s.sol -= solIn;
this.trade(m, 1, solIn, tok, q, p);
toast(`Bought ${p.s || 'coin'} for ${fsol(solIn)} SOL`, 'buy', 2200);
return tok;
},
sell(m, frac) {
const p = this.s.pos[m];
const q = this.px[m];
if (!p || p.tok <= 0) return toast('Nothing to sell', 'warn');
if (!q) return toast('No price for this coin yet', 'warn');
frac = Math.min(1, Math.max(0, +frac));
const tok = frac >= 0.999 ? p.tok : p.tok * frac;
const got = this.quoteSell(q, tok);
const costPart = p.cost * (tok / p.tok);
p.realized += got - costPart; this.s.realized += got - costPart;
p.cost -= costPart; p.tok -= tok; p.got += got; p.last = Date.now();
this.s.sol += got;
this.trade(m, 0, got, tok, q, p);
if (p.tok <= 1e-6) { p.tok = 0; p.cost = 0; p.closed = Date.now(); }
toast(`Sold ${frac >= 0.999 ? 'all' : Math.round(frac * 100) + '%'} ${p.s || ''} for ${fsol(got)} SOL`, 'sell', 2200);
return got;
},
trade(m, buy, solAmt, tok, q, p) {
const k = q.rq * q.rt;
// pool after the fill, so the next paper fill and the page's price follow it
if (buy) { q.rq += solAmt / (1 + q.fee); q.rt = k / q.rq; } else { q.rt += tok; q.rq = k / q.rt; }
const mc = q.mc || 0;
this.s.hist.unshift({ ts: Date.now(), m, n: p.n, s: p.s, img: p.img, buy, sol: solAmt, tok, mc });
if (this.s.hist.length > 1000) this.s.hist.length = 1000;
this.save();
this.emit('trade', { m, buy, sol: solAmt, tok, mc, ts: Date.now() });
},
position(m) { return this.s.pos[m]; },
value(m) { const p = this.s.pos[m], q = this.px[m]; return p && q ? p.tok * (q.price || q.rq / q.rt) : 0; },
totals() {
let value = 0, cost = 0;
for (const [m, p] of Object.entries(this.s.pos)) { if (p.tok > 0) { value += this.value(m); cost += p.cost; } }
return { sol: this.s.sol, value, cost, unreal: value - cost, realized: this.s.realized, total: this.s.sol + value, start: this.s.start };
},
});
live.on('px', m => {
for (const [mint, a] of Object.entries(m.p)) {
const [price, mc, pool, rq, rt, fee, n, s, img] = a;
paper.px[mint] = { ...paper.px[mint], price, mc, pool, rq, rt, fee, n, s, img };
}
paper.emit('prices');
});
paper.syncWatch();