This commit is contained in:
dd 2021-11-28 12:39:07 -05:00
parent b01f124a16
commit fbdda70b98
8 changed files with 3175 additions and 1 deletions

104
.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

View File

@ -1 +1,34 @@
# market-marker-ts
# Mango Markets Market Maker
## UNDER CONSTRUCTION - DO NOT USE
## Setup
To run the market maker you will need:
* A Solana account with some SOL deposited to cover transaction fees
* A Mango Account with some collateral deposited and a name (tip: use the UI)
* Your wallet keypair saved as a JSON file
* `node` and `yarn`
* A clone of this repository
* Dependencies installed with `yarn install`
## Environment Variables
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `ENDPOINT_URL` | `https://mango.rpcpool.com` | Your RPC node endpoint |
| `KEYPAIR` | `${HOME}/.config/solana/id.json` | The location of your wallet keypair |
| `PARAMS` | `params/default.json` | path to params file |
## Market Maker Params
### See params/default.json for an example
| `group` | `mainnet.1` | Name of the group in ids.json |
| `interval` | `10000` | Milliseconds to wait before updating quotes |
| `mangoAccountName` | N/A | The MangoAccount name you input when initializing the MangoAccount via UI |
| `mangoAccountPubkey` | N/A | If no MangoAccount name, just pass in the pubkey |
| `assets` | N/A | Mapping of symbols to trade and their specific params |
| `size_perc` | `0.1` | The size of each order as a percentage of equity |
| `charge` | `0.0010` | Half the quote width |
| `lean_coeff` | `0.0005` | How much to move the quotes per unit size of inventory |
| `bias` | `0` | Fixed amount to bias. Negative values bias downward. e.g. -0.0005 biases down 5bps |
## Setup systemd

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "market-marker",
"version": "1.0.0",
"repository": "git@github.com:blockworks-foundation/market-maker-ts.git",
"license": "MIT",
"main": "lib/src/index.js",
"types": "lib/src/index.d.ts",
"scripts": {
"mm": "ts-node src/mm.ts",
"format": "prettier --check ."
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.1",
"@types/big.js": "^6.1.1",
"@types/bn.js": "^5.1.0",
"@types/chai": "^4.2.14",
"@types/jest": "^26.0.9",
"@types/mocha": "^8.2.0",
"@types/node": "^15.12.4",
"@types/yargs": "latest",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"chai": "^4.3.4",
"cross-env": "^7.0.2",
"eslint": "^7.28.0",
"eslint-config-prettier": "^7.2.0",
"mocha": "^8.4.0",
"prettier": "^2.0.5",
"ts-node": "^9.1.1",
"typedoc": "^0.22.5",
"typescript": "^4.1.3"
},
"files": [
"lib"
],
"prettier": {
"singleQuote": true,
"trailingComma": "all"
},
"dependencies": {
"@blockworks-foundation/mango-client": "git+https://github.com/blockworks-foundation/mango-client-v3.git",
"@project-serum/anchor": "^0.16.2",
"@project-serum/serum": "0.13.55",
"@project-serum/sol-wallet-adapter": "^0.2.0",
"@solana/spl-token": "^0.1.6",
"@solana/web3.js": "1.21.0",
"axios": "^0.21.1",
"big.js": "^6.1.1",
"bigint-buffer": "^1.1.5",
"bn.js": "^5.2.0",
"buffer-layout": "^1.2.1",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0"
},
"resolutions": {
"bn.js": "5.1.3",
"@types/bn.js": "5.1.0",
"@solana/web3.js": "^1.21.0"
}
}

100
params/default.json Normal file
View File

@ -0,0 +1,100 @@
{
"group": "mainnet.1",
"mangoAccountName": "test",
"mangoAccountPubkey": "optional pubkey string if your mango account doesn't have name",
"interval": 5000,
"batch": 2,
"assets": {
"MNGO": {
"perp": {
"charge": 0.0020,
"sizePerc": 0.02,
"leanCoeff": 0.0010,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"BTC": {
"perp": {
"charge": 0.0005,
"sizePerc": 0.1,
"leanCoeff": 0.00025,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"ETH": {
"perp": {
"charge": 0.0020,
"sizePerc": 0.02,
"leanCoeff": 0.0010,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"SOL": {
"perp": {
"charge": 0.0010,
"sizePerc": 0.1,
"leanCoeff": 0.0005,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"SRM": {
"perp": {
"charge": 0.0020,
"sizePerc": 0.02,
"leanCoeff": 0.0010,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"RAY": {
"perp": {
"charge": 0.0020,
"sizePerc": 0.02,
"leanCoeff": 0.0010,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"FTT": {
"perp": {
"charge": 0.0020,
"sizePerc": 0.02,
"leanCoeff": 0.0010,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
},
"ADA": {
"perp": {
"charge": 0.0010,
"sizePerc": 0.1,
"leanCoeff": 0.0005,
"bias": 0.0,
"requoteThresh": 0.0002,
"takeSpammers": true,
"spammerCharge": 2
}
}
}
}

521
src/mm.ts Normal file
View File

@ -0,0 +1,521 @@
import {
Account,
Commitment,
Connection,
Transaction,
TransactionInstruction,
} from '@solana/web3.js';
import fs from 'fs';
import os from 'os';
import { BN } from 'bn.js';
import {
BookSide,
BookSideLayout,
Cluster,
Config,
getMultipleAccounts,
getPerpMarketByBaseSymbol,
GroupConfig,
IDS,
makeCancelAllPerpOrdersInstruction,
makePlacePerpOrderInstruction,
MangoAccount,
MangoAccountLayout,
MangoCache,
MangoCacheLayout,
MangoClient,
MangoGroup,
ONE_BN,
PerpMarket,
PerpMarketConfig,
sleep,
zeroKey,
} from '@blockworks-foundation/mango-client';
import { OpenOrders } from '@project-serum/serum';
import path from 'path';
import { loadMangoAccountWithName, loadMangoAccountWithPubkey } from './utils';
const params = JSON.parse(
fs.readFileSync(
process.env.PARAMS || path.resolve(__dirname, '../params/default.json'),
'utf-8',
),
);
const payer = new Account(
JSON.parse(
fs.readFileSync(
process.env.KEYPAIR || os.homedir() + '/.config/solana/id.json',
'utf-8',
),
),
);
const config = new Config(IDS);
const groupIds = config.getGroupWithName(params.group) as GroupConfig;
if (!groupIds) {
throw new Error(`Group ${params.group} not found`);
}
const cluster = groupIds.cluster as Cluster;
const mangoProgramId = groupIds.mangoProgramId;
const mangoGroupKey = groupIds.publicKey;
const control = { isRunning: true, interval: params.interval };
type MarketContext = {
marketName: string;
params: any;
config: PerpMarketConfig;
market: PerpMarket;
marketIndex: number;
bids: BookSide;
asks: BookSide;
};
/**
* Load MangoCache, MangoAccount and Bids and Asks for all PerpMarkets using only
* one RPC call.
*/
async function loadAccountAndMarketState(
connection: Connection,
group: MangoGroup,
oldMangoAccount: MangoAccount,
marketContexts: MarketContext[],
): Promise<{
cache: MangoCache;
mangoAccount: MangoAccount;
marketContexts: MarketContext[];
}> {
const inBasketOpenOrders = oldMangoAccount
.getOpenOrdersKeysInBasket()
.filter((pk) => !pk.equals(zeroKey));
const allAccounts = [
group.mangoCache,
oldMangoAccount.publicKey,
...inBasketOpenOrders,
...marketContexts.map((marketContext) => marketContext.market.bids),
...marketContexts.map((marketContext) => marketContext.market.asks),
];
const accountInfos = await getMultipleAccounts(connection, allAccounts);
const cache = new MangoCache(
accountInfos[0].publicKey,
MangoCacheLayout.decode(accountInfos[0].accountInfo.data),
);
const mangoAccount = new MangoAccount(
accountInfos[1].publicKey,
MangoAccountLayout.decode(accountInfos[1].accountInfo.data),
);
const openOrdersAis = accountInfos.slice(2, 2 + inBasketOpenOrders.length);
for (let i = 0; i < openOrdersAis.length; i++) {
const ai = openOrdersAis[i];
const marketIndex = mangoAccount.spotOpenOrders.findIndex((soo) =>
soo.equals(ai.publicKey),
);
mangoAccount.spotOpenOrdersAccounts[marketIndex] =
OpenOrders.fromAccountInfo(
ai.publicKey,
ai.accountInfo,
group.dexProgramId,
);
}
accountInfos
.slice(
2 + inBasketOpenOrders.length,
2 + inBasketOpenOrders.length + marketContexts.length,
)
.forEach((ai, i) => {
marketContexts[i].bids = new BookSide(
ai.publicKey,
marketContexts[i].market,
BookSideLayout.decode(ai.accountInfo.data),
);
});
accountInfos
.slice(
2 + inBasketOpenOrders.length + marketContexts.length,
2 + inBasketOpenOrders.length + 2 * marketContexts.length,
)
.forEach((ai, i) => {
marketContexts[i].asks = new BookSide(
ai.publicKey,
marketContexts[i].market,
BookSideLayout.decode(ai.accountInfo.data),
);
});
return {
cache,
mangoAccount,
marketContexts,
};
}
async function fullMarketMaker() {
const connection = new Connection(
process.env.ENDPOINT_URL || config.cluster_urls[cluster],
'processed' as Commitment,
);
const client = new MangoClient(connection, mangoProgramId);
// load group
const mangoGroup = await client.getMangoGroup(mangoGroupKey);
// load mangoAccount
let mangoAccount: MangoAccount;
if (params.mangoAccountName) {
mangoAccount = await loadMangoAccountWithName(
client,
mangoGroup,
payer,
params.mangoAccountName,
);
} else if (params.mangoAccountPubkey) {
mangoAccount = await loadMangoAccountWithPubkey(
client,
mangoGroup,
payer,
params.mangoAccountPubkey,
);
} else {
throw new Error(
'Please add mangoAccountName or mangoAccountPubkey to params file',
);
}
const marketContexts: MarketContext[] = [];
for (const baseSymbol in params.assets) {
const perpMarketConfig = getPerpMarketByBaseSymbol(
groupIds,
baseSymbol,
) as PerpMarketConfig;
const perpMarket = await client.getPerpMarket(
perpMarketConfig.publicKey,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals,
);
marketContexts.push({
marketName: perpMarketConfig.name,
params: params.assets[baseSymbol].perp,
config: perpMarketConfig,
market: perpMarket,
marketIndex: perpMarketConfig.marketIndex,
bids: await perpMarket.loadBids(connection),
asks: await perpMarket.loadAsks(connection),
});
}
process.on('SIGINT', function () {
console.log('Caught keyboard interrupt. Canceling orders');
control.isRunning = false;
onExit(client, payer, mangoGroup, mangoAccount, marketContexts);
});
while (control.isRunning) {
try {
const state = await loadAccountAndMarketState(
connection,
mangoGroup,
mangoAccount,
marketContexts,
);
mangoAccount = state.mangoAccount;
let j = 0;
let tx = new Transaction();
const txProms: any[] = [];
for (let i = 0; i < marketContexts.length; i++) {
const instrSet = makeMarketUpdateInstructions(
mangoGroup,
state.cache,
mangoAccount,
marketContexts[i],
);
if (instrSet.length > 0) {
instrSet.forEach((ix) => tx.add(ix));
j++;
if (j === params.batch) {
txProms.push(client.sendTransaction(tx, payer, []));
tx = new Transaction();
j = 0;
}
}
}
if (tx.instructions.length) {
txProms.push(client.sendTransaction(tx, payer, []));
}
if (txProms.length) {
const txids = await Promise.all(txProms);
txids.forEach((txid) => console.log(`success ${txid.toString()}`));
}
} catch (e) {
console.log(e);
} finally {
console.log(
`${new Date().toUTCString()} sleeping for ${control.interval / 1000}s`,
);
await sleep(control.interval);
}
}
}
function makeMarketUpdateInstructions(
group: MangoGroup,
cache: MangoCache,
mangoAccount: MangoAccount,
marketContext: MarketContext,
): TransactionInstruction[] {
// Right now only uses the perp
const marketIndex = marketContext.marketIndex;
const market = marketContext.market;
const bids = marketContext.bids;
const asks = marketContext.asks;
const fairValue = group.getPrice(marketIndex, cache).toNumber();
const equity = mangoAccount.computeValue(group, cache).toNumber();
const perpAccount = mangoAccount.perpAccounts[marketIndex];
// TODO look at event queue as well for unprocessed fills
const basePos = perpAccount.getBasePositionUi(market);
const sizePerc = marketContext.params.sizePerc;
const leanCoeff = marketContext.params.leanCoeff;
const charge = marketContext.params.charge;
const bias = marketContext.params.bias;
const requoteThresh = marketContext.params.requoteThresh;
const takeSpammers = marketContext.params.takeSpammers;
const spammerCharge = marketContext.params.spammerCharge;
const size = (equity * sizePerc) / fairValue;
const lean = (-leanCoeff * basePos) / size;
const bidPrice = fairValue * (1 - charge + lean + bias);
const askPrice = fairValue * (1 + charge + lean + bias);
// TODO volatility adjustment
const [modelBidPrice, nativeBidSize] = market.uiToNativePriceQuantity(
bidPrice,
size,
);
const [modelAskPrice, nativeAskSize] = market.uiToNativePriceQuantity(
askPrice,
size,
);
const bestBid = bids.getBest();
const bestAsk = asks.getBest();
const bookAdjBid =
bestAsk !== undefined
? BN.min(bestAsk.priceLots.sub(ONE_BN), modelBidPrice)
: modelBidPrice;
const bookAdjAsk =
bestBid !== undefined
? BN.max(bestBid.priceLots.add(ONE_BN), modelAskPrice)
: modelAskPrice;
// TODO use order book to requote if size has changed
const openOrders = mangoAccount
.getPerpOpenOrders()
.filter((o) => o.marketIndex === marketIndex);
let moveOrders = openOrders.length === 0 || openOrders.length > 2;
for (const o of openOrders) {
console.log(
`${o.side} ${o.price.toString()} -> ${
o.side === 'buy' ? bookAdjBid.toString() : bookAdjAsk.toString()
}`,
);
if (o.side === 'buy') {
if (
Math.abs(o.price.toNumber() / bookAdjBid.toNumber() - 1) > requoteThresh
) {
moveOrders = true;
}
} else {
if (
Math.abs(o.price.toNumber() / bookAdjAsk.toNumber() - 1) > requoteThresh
) {
moveOrders = true;
}
}
}
// Start building the transaction
const instructions: TransactionInstruction[] = [];
/*
Clear 1 lot size orders at the top of book that bad people use to manipulate the price
*/
if (
takeSpammers &&
bestBid !== undefined &&
bestBid.sizeLots.eq(ONE_BN) &&
bestBid.priceLots.toNumber() / modelAskPrice.toNumber() - 1 >
spammerCharge * charge + 0.0005
) {
console.log(`${marketContext.marketName} taking best bid spammer`);
const takerSell = makePlacePerpOrderInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
cache.publicKey,
market.publicKey,
market.bids,
market.asks,
market.eventQueue,
mangoAccount.getOpenOrdersKeysInBasket(),
bestBid.priceLots,
ONE_BN,
new BN(Date.now()),
'sell',
'ioc',
);
instructions.push(takerSell);
} else if (
takeSpammers &&
bestAsk !== undefined &&
bestAsk.sizeLots.eq(ONE_BN) &&
modelBidPrice.toNumber() / bestAsk.priceLots.toNumber() - 1 >
spammerCharge * charge + 0.0005
) {
console.log(`${marketContext.marketName} taking best ask spammer`);
const takerBuy = makePlacePerpOrderInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
cache.publicKey,
market.publicKey,
market.bids,
market.asks,
market.eventQueue,
mangoAccount.getOpenOrdersKeysInBasket(),
bestAsk.priceLots,
ONE_BN,
new BN(Date.now()),
'buy',
'ioc',
);
instructions.push(takerBuy);
}
if (moveOrders) {
// cancel all, requote
const cancelAllInstr = makeCancelAllPerpOrdersInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
market.publicKey,
market.bids,
market.asks,
new BN(20),
);
const placeBidInstr = makePlacePerpOrderInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
cache.publicKey,
market.publicKey,
market.bids,
market.asks,
market.eventQueue,
mangoAccount.getOpenOrdersKeysInBasket(),
bookAdjBid,
nativeBidSize,
new BN(Date.now()),
'buy',
'postOnlySlide',
);
const placeAskInstr = makePlacePerpOrderInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
cache.publicKey,
market.publicKey,
market.bids,
market.asks,
market.eventQueue,
mangoAccount.getOpenOrdersKeysInBasket(),
bookAdjAsk,
nativeAskSize,
new BN(Date.now()),
'sell',
'postOnlySlide',
);
instructions.push(cancelAllInstr);
instructions.push(placeBidInstr);
instructions.push(placeAskInstr);
} else {
console.log(
`${marketContext.marketName} Not requoting. No need to move orders`,
);
}
return instructions;
}
async function onExit(
client: MangoClient,
payer: Account,
group: MangoGroup,
mangoAccount: MangoAccount,
marketContexts: MarketContext[],
) {
await sleep(control.interval);
mangoAccount = await client.getMangoAccount(
mangoAccount.publicKey,
group.dexProgramId,
);
let tx = new Transaction();
const txProms: any[] = [];
for (let i = 0; i < marketContexts.length; i++) {
const mc = marketContexts[i];
const cancelAllInstr = makeCancelAllPerpOrdersInstruction(
mangoProgramId,
group.publicKey,
mangoAccount.publicKey,
payer.publicKey,
mc.market.publicKey,
mc.market.bids,
mc.market.asks,
new BN(20),
);
tx.add(cancelAllInstr);
if (tx.instructions === params.batch) {
txProms.push(client.sendTransaction(tx, payer, []));
tx = new Transaction();
}
}
if (tx.instructions.length) {
txProms.push(client.sendTransaction(tx, payer, []));
}
const txids = await Promise.all(txProms);
txids.forEach((txid) => {
console.log(`cancel successful: ${txid.toString()}`);
});
process.exit();
}
function startMarketMaker() {
if (control.isRunning) {
fullMarketMaker().finally(startMarketMaker);
}
}
process.on('unhandledRejection', function (err, promise) {
console.error(
'Unhandled rejection (promise: ',
promise,
', reason: ',
err,
').',
);
});
startMarketMaker();

45
src/utils.ts Normal file
View File

@ -0,0 +1,45 @@
import { Account, PublicKey } from '@solana/web3.js';
import {
MangoAccount,
MangoClient,
MangoGroup,
} from '@blockworks-foundation/mango-client';
export async function loadMangoAccountWithName(
client: MangoClient,
mangoGroup: MangoGroup,
payer: Account,
mangoAccountName: string,
): Promise<MangoAccount> {
const ownerAccounts = await client.getMangoAccountsForOwner(
mangoGroup,
payer.publicKey,
true,
);
for (const ownerAccount of ownerAccounts) {
if (mangoAccountName === ownerAccount.name) {
return ownerAccount;
}
}
throw new Error(`mangoAccountName: ${mangoAccountName} not found`);
}
export async function loadMangoAccountWithPubkey(
client: MangoClient,
mangoGroup: MangoGroup,
payer: Account,
mangoAccountPk: PublicKey,
): Promise<MangoAccount> {
const mangoAccount = await client.getMangoAccount(
mangoAccountPk,
mangoGroup.dexProgramId,
);
if (!mangoAccount.owner.equals(payer.publicKey)) {
throw new Error(
`Invalid MangoAccount owner: ${mangoAccount.owner.toString()}; expected: ${payer.publicKey.toString()}`,
);
}
return mangoAccount;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"outDir": "./lib",
"target": "es6",
"lib": ["es2019"],
"allowJs": true,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"noImplicitAny": false,
"resolveJsonModule": true,
"sourceMap": true,
"esModuleInterop": true
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.js", "node_modules", "**/node_modules"]
}

2292
yarn.lock Normal file

File diff suppressed because it is too large Load Diff