feat: implement buy-low-sell-high market maker (no splitPosition)
New market maker strategy that places limit BUY on UP+DOWN at low price (default 2c), then sells at target price (default 3c) when filled. Key features: - Concurrent limit BUY on both sides (multi-thread style via Promise.all) - Immediate partial sell placement on partial fill detection - Cancel opposite side when one fills completely - WebSocket orderbook client for real-time simulation visualization - Both TUI (maker.js) and bot (maker-bot.js) entry points Files: - services/makerDetector.js: market detection (reuses slug pattern) - services/makerExecutor.js: core buy-low-sell-high strategy - services/makerWs.js: WebSocket orderbook for live data - maker.js: TUI version with blessed dashboard - maker-bot.js: PM2/VPS plain-text version Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9e78fb1bfe
commit
991fd6caa4
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* maker.js
|
||||
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
|
||||
*
|
||||
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
|
||||
* No splitPosition — pure orderbook-based market making.
|
||||
*
|
||||
* Run with: npm run maker (live)
|
||||
* npm run maker-sim (simulation with real orderbook via WebSocket)
|
||||
*/
|
||||
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||
import { executeMakerStrategy, getActiveMakerPositions } from './services/makerExecutor.js';
|
||||
import { OrderbookWs } from './services/makerWs.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
|
||||
|
||||
const orderbookWs = new OrderbookWs();
|
||||
let activeWsTokens = { up: null, down: null };
|
||||
|
||||
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
// Mode
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
// Maker Config
|
||||
lines.push('{bold}MAKER CONFIG{/bold}');
|
||||
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Duration : ${config.makerDuration}`);
|
||||
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
|
||||
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
|
||||
lines.push(` Size : ${config.makerTradeSize} shares/side`);
|
||||
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
|
||||
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
|
||||
lines.push(` Cut loss : ${config.makerCutLossTime}s before close`);
|
||||
lines.push('');
|
||||
|
||||
// Active positions
|
||||
const positions = getActiveMakerPositions();
|
||||
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||
} else {
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 32);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||
|
||||
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||
|
||||
// UP side
|
||||
const upFill = pos.up.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const upSold = pos.up.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` UP ${upFill}${upSold}`);
|
||||
|
||||
// DOWN side
|
||||
const downFill = pos.down.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const downSold = pos.down.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` DOWN ${downFill}${downSold}`);
|
||||
|
||||
// P&L
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const pnlColor = pnl >= 0 ? 'green' : 'red';
|
||||
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Orderbook display (simulation mode)
|
||||
if (config.dryRun && activeWsTokens.up) {
|
||||
lines.push('{bold}LIVE ORDERBOOK{/bold}');
|
||||
|
||||
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
|
||||
if (!tokenId) continue;
|
||||
const book = orderbookWs.getBook(tokenId);
|
||||
const lastPrice = orderbookWs.getLastPrice(tokenId);
|
||||
|
||||
lines.push(` {cyan-fg}${label}{/cyan-fg} (last: $${lastPrice.toFixed(3)})`);
|
||||
|
||||
// Top 3 asks (reversed so lowest is closest to spread)
|
||||
const topAsks = book.asks.slice(0, 3).reverse();
|
||||
for (const ask of topAsks) {
|
||||
lines.push(` {red-fg}ASK $${ask.price.toFixed(3)} × ${ask.size.toFixed(0)}{/red-fg}`);
|
||||
}
|
||||
|
||||
// Spread
|
||||
const bestBid = book.bids[0]?.price || 0;
|
||||
const bestAsk = book.asks[0]?.price || 0;
|
||||
if (bestBid && bestAsk) {
|
||||
lines.push(` {gray-fg}--- spread: $${(bestAsk - bestBid).toFixed(3)} ---{/gray-fg}`);
|
||||
}
|
||||
|
||||
// Top 3 bids
|
||||
const topBids = book.bids.slice(0, 3);
|
||||
for (const bid of topBids) {
|
||||
lines.push(` {green-fg}BID $${bid.price.toFixed(3)} × ${bid.size.toFixed(0)}{/green-fg}`);
|
||||
}
|
||||
|
||||
// Recent trades
|
||||
const trades = orderbookWs.getRecentTrades(tokenId, 3);
|
||||
if (trades.length > 0) {
|
||||
lines.push(` Trades:`);
|
||||
for (const t of trades.reverse()) {
|
||||
const color = t.side === 'BUY' ? 'green' : 'red';
|
||||
lines.push(` {${color}-fg}${t.side} $${t.price.toFixed(3)} × ${t.size.toFixed(0)}{/${color}-fg}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 2000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
async function runStrategy(market) {
|
||||
// Connect WebSocket for orderbook visualization in sim mode
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
|
||||
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||
}
|
||||
|
||||
try {
|
||||
await executeMakerStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
// Disconnect WS after strategy ends
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: null, down: null };
|
||||
}
|
||||
|
||||
// Process queued market
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.makerCutLossTime) {
|
||||
logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMakerPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MAKER: shutting down...');
|
||||
stopMakerDetector();
|
||||
orderbookWs.shutdown();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
|
||||
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
|
||||
|
||||
startRefresh();
|
||||
startMakerDetector(handleNewMarket);
|
||||
Reference in New Issue
Block a user