├── .env ├── balances ├── hedge-balance.csv ├── total-balance.csv └── zeta-balance.csv ├── src ├── constants.ts ├── test-connection.ts ├── log.ts ├── initialize_accs.ts ├── blockhash.ts ├── math.ts ├── position-agg.ts ├── lock.ts ├── app.ts ├── configuration.ts ├── types.ts ├── utils.ts ├── webserver.ts ├── trader.ts ├── state.ts └── maker.ts ├── .gitignore ├── tsconfig.json ├── secrets.json ├── secrets.schema.json ├── package.json ├── config.json ├── README.md ├── config.schema.json └── views └── dashboard.hbs /.env: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=info 2 | -------------------------------------------------------------------------------- /balances/hedge-balance.csv: -------------------------------------------------------------------------------- 1 | unixTs, balance 2 | -------------------------------------------------------------------------------- /balances/total-balance.csv: -------------------------------------------------------------------------------- 1 | unixTs, balance 2 | -------------------------------------------------------------------------------- /balances/zeta-balance.csv: -------------------------------------------------------------------------------- 1 | unixTs, balance 2 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const BPS_FACTOR = 10000; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | PRIVATE.md 4 | secrets.uat.json 5 | secrets.prod.json 6 | restart-cnt.txt -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["./src/**/*"], 13 | "exclude": ["**/node_modules/**/*"], 14 | "typedocOptions": { 15 | "entryPoints": ["src/index.ts"], 16 | "out": "docs" 17 | } 18 | } -------------------------------------------------------------------------------- /secrets.json: -------------------------------------------------------------------------------- 1 | { 2 | "makerWallet": [ 3 | 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 4 | 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 5 | 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 6 | 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 111, 7 | 111, 111, 111, 111 8 | ], 9 | "credentials": { 10 | "bybit": { 11 | "apiKey": "ABCD", 12 | "secret": "ABCD" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test-connection.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from "./configuration"; 2 | import { pro } from "ccxt"; 3 | 4 | async function main() { 5 | const config = loadConfig(); 6 | let hedgeExchange = new pro[config.hedgeExchange]( 7 | config.credentials[config.hedgeExchange] 8 | ); 9 | let hedgeOb = await hedgeExchange.watchOrderBook("SOL/USDT:USDT"); 10 | console.log(hedgeOb); 11 | 12 | let balance = await hedgeExchange.fetchBalance({ 13 | coin: "USDT", 14 | }); 15 | 16 | console.log(balance.info.result); 17 | } 18 | 19 | main(); 20 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "tslog"; 2 | 3 | require("dotenv").config({ path: __dirname + "/../.env" }); 4 | export const minLevel = process.env.LOG_LEVEL as 5 | | "silly" 6 | | "trace" 7 | | "debug" 8 | | "info" 9 | | "warn" 10 | | "error"; 11 | export const log: Logger = new Logger({ 12 | name: "maker", 13 | displayFunctionName: true, 14 | displayLoggerName: false, 15 | colorizePrettyLogs: false, 16 | displayFilePath: "hidden", 17 | dateTimePattern: "year-month-dayThour:minute:second.millisecond", 18 | minLevel, 19 | }); 20 | -------------------------------------------------------------------------------- /secrets.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "makerWallet": { "$ref": "#/definitions/Wallet" }, 6 | "credentials": { 7 | "type": "object", 8 | "additionalProperties": { 9 | "type": "object", 10 | "properties": { 11 | "apiKey": { "type": "string" }, 12 | "secret": { "type": "string" } 13 | }, 14 | "required": ["apiKey", "secret"] 15 | } 16 | } 17 | }, 18 | "required": ["makerWallet", "credentials"], 19 | "definitions": { 20 | "Wallet": { 21 | "type": "array", 22 | "items": [{ "type": "integer" }], 23 | "minItems": 64, 24 | "maxItems": 64 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@solana/web3.js": "1.70.3", 4 | "@zetamarkets/anchor": "0.26.0-versioned", 5 | "@zetamarkets/sdk": "1.4.2", 6 | "ajv": "^8.11.2", 7 | "async-mutex": "^0.4.0", 8 | "axios": "^1.4.0", 9 | "bs58": "^4.0.1", 10 | "ccxt": "3.1.46", 11 | "chai": "^4.3.7", 12 | "cmd-ts": "^0.11.0", 13 | "csv-parse": "^5.3.6", 14 | "dotenv": "^10.0.0", 15 | "express": "^4.18.2", 16 | "hbs": "^4.2.0", 17 | "log-timestamp": "^0.3.0", 18 | "mocha": "^10.1.0", 19 | "node-fetch": "^2.6.7", 20 | "ts-node": "^10.9.1", 21 | "tslog": "3.3.4", 22 | "typescript": "^4.4.3", 23 | "uuid": "^8.3.2" 24 | }, 25 | "scripts": { 26 | "test": "mocha -r ts-node/register test/**/*.spec.ts" 27 | }, 28 | "engines": { 29 | "node": "16.x" 30 | }, 31 | "devDependencies": { 32 | "@types/mocha": "^10.0.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/initialize_accs.ts: -------------------------------------------------------------------------------- 1 | // Orchestrates initialization (with fallback), listening to all the feeds, and processing of effects 2 | 3 | import { Wallet, Exchange, utils, CrossClient } from "@zetamarkets/sdk"; 4 | import { loadConfig } from "./configuration"; 5 | import { Connection } from "@solana/web3.js"; 6 | 7 | const REAL_DEPOSIT_AMT = 1000_000000; // fixed-point 6 d,p, == $1000 currently. 8 | 9 | async function main() { 10 | const CONFIG = loadConfig(); 11 | 12 | const connection = new Connection(CONFIG.endpoint, "processed"); 13 | const wallet = new Wallet(CONFIG.makerWallet); 14 | 15 | await Exchange.load({ 16 | network: CONFIG.network, 17 | connection, 18 | opts: utils.defaultCommitment(), 19 | throttleMs: 0, 20 | loadFromStore: true, 21 | }); 22 | 23 | Exchange.toggleAutoPriorityFee(); 24 | 25 | const zetaCrossClient = await CrossClient.load(connection, wallet); 26 | 27 | await zetaCrossClient.deposit(REAL_DEPOSIT_AMT); 28 | 29 | await Exchange.close(); 30 | await zetaCrossClient.close(); 31 | } 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": "mainnet", 3 | "endpoint": "https://api.mainnet-beta.solana.com", 4 | "programId": "ZETAxsqBRek56DhiGXrn75yj2NHU3aYUnxvHXpkf3aD", 5 | "hedgeExchange": "bybit", 6 | 7 | "quoteIntervalMs": 30000, 8 | "markPriceStaleIntervalMs": 5000, 9 | "positionRefreshIntervalMs": 10000, 10 | "riskStatsFetchIntervalMs": 30000, 11 | "lockingIntervalMs": 3000, 12 | "tifExpiryOffsetMs": 30000, 13 | 14 | "cashDeltaHedgeThreshold": 15, 15 | "webServerPort": 85, 16 | 17 | "assets": { 18 | "SOL": { 19 | "maxZetaCashExposure": 10, 20 | "maxNetCashDelta": 1000, 21 | "quoteLotSize": 0.01, 22 | "widthBps": 12, 23 | "requoteBps": 1, 24 | "instruments": [ 25 | { 26 | "marketIndex": 137, 27 | "levels": [ 28 | { "priceIncr": 0.0, "quoteCashDelta": 5 }, 29 | { "priceIncr": 0.0005, "quoteCashDelta": 5 }, 30 | { "priceIncr": 0.001, "quoteCashDelta": 5 }, 31 | { "priceIncr": 0.0015, "quoteCashDelta": 5 }, 32 | { "priceIncr": 0.004, "quoteCashDelta": 5 } 33 | ] 34 | } 35 | ], 36 | "leanBps": 5 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/blockhash.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | Commitment, 4 | RpcResponseAndContext, 5 | BlockhashWithExpiryBlockHeight, 6 | } from "@solana/web3.js"; 7 | 8 | export class BlockhashFetcher { 9 | private connection: Connection; 10 | private commitment: Commitment; 11 | private intervalMs: number; 12 | private refreshIntervalId: NodeJS.Timer; 13 | private blockhashAndContext: RpcResponseAndContext; 14 | 15 | constructor( 16 | url: string, 17 | commitment: Commitment = `finalized`, 18 | intervalMs: number = 200 19 | ) { 20 | this.connection = new Connection(url, commitment); 21 | this.commitment = commitment; 22 | this.intervalMs = intervalMs; 23 | } 24 | 25 | public get blockhash() { 26 | return this.blockhashAndContext?.value; 27 | } 28 | 29 | subscribe() { 30 | this.refreshIntervalId = setInterval( 31 | async () => 32 | (this.blockhashAndContext = 33 | await this.connection.getLatestBlockhashAndContext(this.commitment)), 34 | this.intervalMs 35 | ); 36 | } 37 | 38 | shutdown() { 39 | if (this.refreshIntervalId) clearInterval(this.refreshIntervalId); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/math.ts: -------------------------------------------------------------------------------- 1 | import { TopLevelMsg } from "./types"; 2 | import { utils } from "@zetamarkets/sdk"; 3 | import * as constants from "./constants"; 4 | import { Spread } from "./types"; 5 | 6 | // Weighted midpoint 7 | export function calculateFair(msg: TopLevelMsg): number { 8 | let pb = msg.topLevel.bid.price; 9 | let qb = msg.topLevel.bid.size; 10 | let pa = msg.topLevel.ask.price; 11 | let qa = msg.topLevel.ask.size; 12 | return (pb * qa + pa * qb) / (qa + qb); 13 | } 14 | 15 | export function roundTickSize(price: number, bid: boolean) { 16 | const tickSize = 1000; 17 | return bid 18 | ? Math.floor(price / tickSize) * tickSize 19 | : (Math.floor(price / tickSize) + 1) * tickSize; 20 | } 21 | 22 | export function roundLotSize(size: number, lotSize: number) { 23 | return Number((Math.floor(size / lotSize) * lotSize).toFixed(3)); 24 | } 25 | 26 | export function calculateSpread( 27 | price: number, 28 | spreadBps: number, 29 | totalDeltas: number, 30 | maxCashDelta: number, 31 | leanBps: number 32 | ): Spread { 33 | let notionalDelta = totalDeltas * price; 34 | let newLean = 35 | notionalDelta > 0 36 | ? Math.max( 37 | (-notionalDelta / maxCashDelta) * (spreadBps + leanBps), 38 | -(spreadBps + leanBps) 39 | ) 40 | : Math.min( 41 | (-notionalDelta / maxCashDelta) * (spreadBps + leanBps), 42 | spreadBps + leanBps 43 | ); 44 | 45 | let newBidEdge = (price * (-spreadBps + newLean)) / constants.BPS_FACTOR; 46 | let newAskEdge = (price * (spreadBps + newLean)) / constants.BPS_FACTOR; 47 | let newBid = price + newBidEdge; 48 | let newAsk = price + newAskEdge; 49 | 50 | return { 51 | bid: newBid, 52 | ask: newAsk, 53 | }; 54 | } 55 | 56 | export function calculateSpreadNoLean( 57 | price: number, 58 | spreadBps: number 59 | ): Spread { 60 | let bidEdge = (price * -spreadBps) / constants.BPS_FACTOR; 61 | let askEdge = (price * spreadBps) / constants.BPS_FACTOR; 62 | let bid = price + bidEdge; 63 | let ask = price + askEdge; 64 | return { 65 | bid: bid, 66 | ask: ask, 67 | }; 68 | } 69 | 70 | export function convertPriceToOrderPrice(price: number, bid: boolean): number { 71 | let orderPrice = utils.convertDecimalToNativeInteger(price); 72 | return roundTickSize(orderPrice, bid); 73 | } 74 | -------------------------------------------------------------------------------- /src/position-agg.ts: -------------------------------------------------------------------------------- 1 | import { constants } from "@zetamarkets/sdk"; 2 | import { MarketIndex, Venue } from "./types"; 3 | 4 | export interface PositionKey { 5 | venue?: Venue; 6 | asset?: constants.Asset; 7 | marketIndex?: MarketIndex; 8 | } 9 | 10 | export function asPositionKey(asStr: string): PositionKey { 11 | const [venue, asset, marketIndex] = asStr.split("-"); 12 | return { 13 | venue: venue == "" ? undefined : (venue as Venue), 14 | asset: asset == "" ? undefined : (asset as constants.Asset), 15 | marketIndex: +marketIndex, 16 | }; 17 | } 18 | 19 | export function asString(asKey: PositionKey): string { 20 | return `${asKey.venue ?? ""}-${asKey.asset ?? ""}-${asKey.marketIndex ?? ""}`; 21 | } 22 | 23 | export class PositionAgg { 24 | private positions: Map = new Map(); 25 | 26 | set(key: PositionKey, value: number): boolean { 27 | if (!key.venue || !key.asset || !key.marketIndex) 28 | throw new Error(`Need full key for set(): ${JSON.stringify(key)}`); 29 | const keyStr = `${key.venue}-${key.asset}-${key.marketIndex}`; 30 | const current = this.positions.get(keyStr); 31 | const changed = current != value; 32 | if (changed) this.positions.set(keyStr, value); 33 | return changed; 34 | } 35 | 36 | get(key: PositionKey): [PositionKey, number][] { 37 | return this._getInternal(key).map(([key, value]): [PositionKey, number] => [ 38 | asPositionKey(key), 39 | value, 40 | ]); 41 | } 42 | 43 | getFirst(key: PositionKey): number { 44 | const positions = this.get(key); 45 | return positions.length > 0 ? positions[0][1] : undefined; 46 | } 47 | 48 | sum(key: PositionKey): number { 49 | const positions = this._getInternal(key); 50 | return positions.length == 0 51 | ? undefined 52 | : positions.reduce((soFar, [_, value]) => value + soFar, 0); 53 | } 54 | 55 | clone(): PositionAgg { 56 | const newInstance = new PositionAgg(); 57 | newInstance.positions = new Map(this.positions); 58 | return newInstance; 59 | } 60 | 61 | private _getInternal(key: PositionKey): [string, number][] { 62 | const keyRegexStr = 63 | (key.venue ?? "[A-Za-z]+") + 64 | "-" + 65 | (key.asset ?? "[A-Za-z]+") + 66 | "-" + 67 | (key.marketIndex ?? "[0-9]+"); 68 | const keyRegex = new RegExp(keyRegexStr); 69 | return Array.from(this.positions.entries()).filter(([key, _]) => 70 | keyRegex.test(key) 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lock.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from "async-mutex"; 2 | import { log } from "./log"; 3 | import { sleep } from "./utils"; 4 | 5 | export class LockedRunner { 6 | private waitPeriod: number; 7 | private locks: Map; 8 | private lastRuns: Map; 9 | private lockIds: string[]; 10 | 11 | constructor(waitPeriod: number, lockIds: string[]) { 12 | this.waitPeriod = waitPeriod; 13 | this.lockIds = lockIds; 14 | this.locks = new Map( 15 | lockIds.map((x): [string, Mutex] => { 16 | return [x, new Mutex()]; 17 | }) 18 | ); 19 | this.lastRuns = new Map( 20 | lockIds.map((x): [string, number] => { 21 | return [x, 0]; 22 | }) 23 | ); 24 | } 25 | 26 | async runExclusive( 27 | lockId: string, 28 | onDelay: "reject" | "wait" | "proceed", 29 | run: () => Promise 30 | ): Promise<[T, boolean]> { 31 | if (!this.lockIds.includes(lockId)) 32 | throw new Error(`Unrecognized lockId ${lockId}`); 33 | 34 | const that = this; 35 | async function decoratedRun(): Promise<[T, boolean]> { 36 | const lastRunTs = that.lastRuns.get(lockId); 37 | const delayTs = lastRunTs + that.waitPeriod - Date.now(); 38 | if (delayTs > 0) 39 | switch (onDelay) { 40 | case "wait": 41 | log.debug( 42 | `Waiting ${delayTs}ms for lock ${lockId} (lastRunTs ${lastRunTs})` 43 | ); 44 | await sleep(delayTs); 45 | log.debug( 46 | `Done with the wait for ${delayTs}ms for lock ${lockId} (lastRunTs ${lastRunTs})` 47 | ); 48 | break; 49 | case "reject": 50 | log.debug( 51 | `Rejecting operation under lock ${lockId}, unlock period is ${delayTs}ms in the future (lastRunTs ${lastRunTs})` 52 | ); 53 | return [undefined, false]; 54 | case "proceed": 55 | log.debug( 56 | `Proceeding on operation under lock ${lockId} despite delay ${delayTs}ms (lastRunTs ${lastRunTs})` 57 | ); 58 | break; 59 | } 60 | else 61 | log.debug( 62 | `No delay on lock ${lockId}, executing (lastRunTs ${lastRunTs})` 63 | ); 64 | 65 | const res = await run(); 66 | const finishTs = Date.now(); 67 | log.debug( 68 | `Finished execution on lock ${lockId} @ (lastRunTs ${lastRunTs}, finishTs ${finishTs})` 69 | ); 70 | that.lastRuns.set(lockId, finishTs); 71 | return [res, true]; 72 | } 73 | const lock = this.locks.get(lockId); 74 | if (onDelay == "reject" && lock.isLocked()) { 75 | // premature rejection, not even entering the mutex lock 76 | log.debug( 77 | `Rejecting operation under lock ${lockId}, not attempting the mutex await` 78 | ); 79 | return [undefined, false]; 80 | } 81 | return await lock.runExclusive(decoratedRun); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maker 2 | 3 | Maker bot with ability to provide liquidity to Zeta DEX, and offset via Hedge exchange (eg. bybit) 4 | 5 | [![build](../../workflows/build/badge.svg)](../../actions/workflows/build.yml) 6 | 7 | ## Setup 8 | 9 | 1. Add wallet key and bybit api key to secrets.json. 10 | 2. Choose configuration parameters in config.json. 11 | 3. Add RPC endpoint. 12 | 4. For new wallets, `ts-node src/initialize_accs.ts`, this will deposit a specified amount into Zeta DEX. 13 | 14 | ## Run 15 | 16 | ```sh 17 | npm i 18 | ts-node src/app.ts 19 | ``` 20 | 21 | ## Remote setup 22 | 23 | Expose port 85 used by web APIs. 24 | 25 | ```sh 26 | # allow for port 85 usage 27 | sudo setcap cap_net_bind_service=+ep /usr/bin/node 28 | ``` 29 | 30 | ## Web interface 31 | 32 | ``` 33 | # fetches positions and top level summaries 34 | curl localhost:85/position/zeta # venue: zeta or hedge 35 | curl localhost:85/position/zeta/BTC # venue: zeta or hedge, asset: BTC, ETH or SOL 36 | 37 | # fetches restart counter 38 | curl localhost:85/restart 39 | 40 | # renders auto-refreshed html dashboard 41 | localhost:85/dashboard?refresh=10 # if no refresh param, defaults to 10s 42 | ``` 43 | 44 | ## Configuration Parameters 45 | 46 | ``` 47 | "quoteIntervalMs": 30000, // interval for periodic refresh of all quotes, regardless of price movements 48 | "markPriceStaleIntervalMs": 5000, // MM bot will shutdown if mark prices are stale by more than this time 49 | "positionRefreshIntervalMs": 10000, // How often positions will be refreshed on Zeta and hedge exchange 50 | "riskStatsFetchIntervalMs": 30000, // How often risk stats will be refreshed on Zeta and hedge exchange 51 | "lockingIntervalMs": 3000, // Mutex wrapper config, for resource locking 52 | "tifExpiryOffsetMs": 30000, // If using TIF orders, the maximum time orders will be live for 53 | 54 | "cashDeltaHedgeThreshold": 10000, // in $ terms, the delta mm bot can reach between Zeta and Hedge exchange before it auto-hedges 55 | "webServerPort": 85, 56 | 57 | "assets": { 58 | "SOL": { 59 | "maxZetaCashExposure": 25000, // in $ terms, maximum position mm bot will accumulate on zeta before it only tries to reduce it's position 60 | "maxNetCashDelta": 20000, // in $ terms, another safety barrier where if the delta between Zeta and hedge exchange >= maxNetCashDelta, we stop quoting 61 | "quoteLotSize": 0.01, // The number of lots to quote incrementally 62 | "widthBps": 12, // quote width 63 | "requoteBps": 1, // if mark price moves requoteBps, mm bot will requote 64 | "instruments": [ // each level is how much size you are quoting at the respective price increment in $ terms. 65 | { 66 | "marketIndex": 137, 67 | "levels": [ 68 | { "priceIncr": 0.0, "quoteCashDelta": 200 }, 69 | { "priceIncr": 0.0005, "quoteCashDelta": 1000 }, 70 | { "priceIncr": 0.001, "quoteCashDelta": 5000 }, 71 | { "priceIncr": 0.0015, "quoteCashDelta": 5000 }, 72 | { "priceIncr": 0.004, "quoteCashDelta": 15000 } 73 | ] 74 | } 75 | ], 76 | "leanBps": 5 // how much the mm will lean its quotes as it accumulates a position either long or short 77 | } 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "network": { "type": "string", "enum": ["devnet", "mainnet"] }, 6 | "endpoint": { "type": "string" }, 7 | "programId": { "type": "string" }, 8 | "hedgeExchange": { "type": "string" }, 9 | "quoteIntervalMs": { "type": "integer", "minimum": 0 }, 10 | "riskStatsFetchIntervalMs": { "type": "integer", "minimum": 0 }, 11 | "markPriceStaleIntervalMs": { "type": "integer", "minimum": 0 }, 12 | "positionRefreshIntervalMs": { "type": "integer", "minimum": 0 }, 13 | "tifExpiryOffsetMs": { "type": "integer", "minimum": 0 }, 14 | "lockingIntervalMs": { "type": "integer", "minimum": 0 }, 15 | "cashDeltaHedgeThreshold": { "type": "integer", "minimum": 15 }, 16 | "webServerPort": { "type": "integer" }, 17 | "assets": { 18 | "type": "object", 19 | "properties": { 20 | "SOL": { "$ref": "#/definitions/Asset" }, 21 | "ETH": { "$ref": "#/definitions/Asset" }, 22 | "BTC": { "$ref": "#/definitions/Asset" }, 23 | "APT": { "$ref": "#/definitions/Asset" }, 24 | "ARB": { "$ref": "#/definitions/Asset" } 25 | }, 26 | "additionalProperties": false 27 | } 28 | }, 29 | "required": [ 30 | "network", 31 | "endpoint", 32 | "programId", 33 | "hedgeExchange", 34 | "quoteIntervalMs", 35 | "markPriceStaleIntervalMs", 36 | "riskStatsFetchIntervalMs", 37 | "positionRefreshIntervalMs", 38 | "tifExpiryOffsetMs", 39 | "lockingIntervalMs", 40 | "cashDeltaHedgeThreshold" 41 | ], 42 | "definitions": { 43 | "Wallet": { 44 | "type": "array", 45 | "items": [{ "type": "integer" }], 46 | "minItems": 64, 47 | "maxItems": 64 48 | }, 49 | "Asset": { 50 | "type": "object", 51 | "properties": { 52 | "maxZetaCashExposure": { "type": "integer", "minimum": 1 }, 53 | "maxNetCashDelta": { "type": "integer", "minimum": 1 }, 54 | "quoteLotSize": { "type": "number" }, 55 | "widthBps": { "type": "integer" }, 56 | "requoteBps": { "type": "integer" }, 57 | "instruments": { 58 | "type": "array", 59 | "items": [{ "$ref": "#/definitions/Instrument" }] 60 | }, 61 | "leanBps": { "type": "integer" } 62 | }, 63 | "required": [ 64 | "maxZetaCashExposure", 65 | "maxNetCashDelta", 66 | "quoteLotSize", 67 | "widthBps", 68 | "requoteBps", 69 | "instruments" 70 | ] 71 | }, 72 | "Instrument": { 73 | "type": "object", 74 | "properties": { 75 | "marketIndex": { "type": "integer", "enum": [22, 45, 137] }, 76 | "levels": { 77 | "type": "array", 78 | "items": [{ "$ref": "#/definitions/InstrumentLevel" }] 79 | } 80 | }, 81 | "additionalProperties": false, 82 | "required": ["marketIndex", "levels"] 83 | }, 84 | "InstrumentLevel": { 85 | "type": "object", 86 | "properties": { 87 | "priceIncr": { "type": "number" }, 88 | "quoteCashDelta": { "type": "number" } 89 | }, 90 | "additionalProperties": false, 91 | "required": ["priceIncr", "quoteCashDelta"] 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | #!ts-node 2 | 3 | // Orchestrates initialization (with fallback), listening to all the feeds, and processing of effects 4 | 5 | import { loadConfig } from "./configuration"; 6 | import { bumpRestartCount, schedule } from "./utils"; 7 | import { startExpress } from "./webserver"; 8 | import { log } from "./log"; 9 | import { Maker } from "./maker"; 10 | import { constants } from "@zetamarkets/sdk"; 11 | 12 | async function main() { 13 | const config = loadConfig(); 14 | log.info(`Starting maker with config: 15 | • network: ${config.network} 16 | • endpoint: ${config.endpoint} 17 | • programId: ${config.programId} 18 | • walletPubkey: ${config.makerWallet.publicKey.toString()} 19 | • hedgeExchange: ${config.hedgeExchange} 20 | • quoteIntervalMs: ${config.quoteIntervalMs} 21 | • markPriceStaleIntervalMs: ${config.markPriceStaleIntervalMs} 22 | • positionRefreshIntervalMs: ${config.positionRefreshIntervalMs} 23 | • riskStatsFetchIntervalMs: ${config.riskStatsFetchIntervalMs} 24 | • tifExpiryOffsetMs: ${config.tifExpiryOffsetMs} 25 | • lockingIntervalMs: ${config.lockingIntervalMs} 26 | • cashDeltaHedgeThreshold: ${config.cashDeltaHedgeThreshold} 27 | • webServerPort: ${config.webServerPort} 28 | • assets: ${Array.from(config.assets.entries()) 29 | .map( 30 | ([asset, assetConfig]) => ` 31 | • ${asset}: 32 | • maxZetaCashExposure: ${assetConfig.maxZetaCashExposure} 33 | • maxNetCashDelta: ${assetConfig.maxNetCashDelta} 34 | • quoteLotSize: ${assetConfig.quoteLotSize} 35 | • requoteBps: ${assetConfig.requoteBps} 36 | • widthBps: ${assetConfig.widthBps} 37 | • instruments: ${assetConfig.instruments 38 | .map( 39 | (instrument) => ` 40 | • marketIndex: ${instrument.marketIndex} 41 | • levels: ${instrument.levels 42 | .map( 43 | ({ priceIncr, quoteCashDelta }) => 44 | `{priceIncr: ${priceIncr}, quoteCashDelta: ${quoteCashDelta}}` 45 | ) 46 | .join()}` 47 | ) 48 | .join()}` 49 | ) 50 | .join()}`); 51 | const allAssets = Array.from(config.assets.keys()); 52 | 53 | const maker = new Maker(config); 54 | await maker.initialize(); 55 | 56 | const die = async (reason: string) => { 57 | log.info(`Shutting down due to ${reason}`); 58 | await maker.shutdown(); 59 | process.exit(1); 60 | }; 61 | schedule(async () => { 62 | const now = Date.now(); 63 | const theosTs = allAssets.map( 64 | (asset): [constants.Asset, number, number] => [ 65 | asset, 66 | maker.getTheo(asset)?.timestamp, 67 | now - maker.getTheo(asset)?.timestamp, 68 | ] 69 | ); 70 | const staleTheosTs = theosTs.filter( 71 | ([_1, _2, age]) => age > config.markPriceStaleIntervalMs 72 | ); 73 | if (staleTheosTs.length > 0) 74 | await die( 75 | `stale mark prices 76 | ${staleTheosTs 77 | .map( 78 | ([asset, ts, age]) => 79 | `- ${asset}, lastUpdated: ${new Date(ts).toLocaleString()}, age: ${age}ms` 80 | ) 81 | .join(`\n`)}` 82 | ); 83 | else 84 | log.debug( 85 | `stale mark price check success: ${theosTs.map(([asset, _, age]) => 86 | JSON.stringify({ asset, age }) 87 | )}` 88 | ); 89 | }, config.markPriceStaleIntervalMs); 90 | 91 | const restartCnt = bumpRestartCount(); 92 | startExpress( 93 | config.webServerPort, 94 | config.cashDeltaHedgeThreshold, 95 | allAssets, 96 | config.network, 97 | restartCnt, 98 | maker 99 | ); 100 | 101 | process.on("SIGINT", async () => { 102 | await die("SIGINT"); 103 | }); 104 | process.on("SIGTERM", async () => { 105 | await die("SIGTERM"); 106 | }); 107 | } 108 | 109 | main(); 110 | -------------------------------------------------------------------------------- /src/configuration.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey, Keypair } from "@solana/web3.js"; 2 | import { utils, network, Network, assets, constants } from "@zetamarkets/sdk"; 3 | import { MarketIndex } from "./types"; 4 | import ajv from "ajv"; 5 | 6 | // Before parsing. 7 | export interface ConfigRaw { 8 | network: string; 9 | endpoint: string; 10 | programId: string; 11 | mintAuthority: number[]; 12 | webServerPort: number; 13 | hedgeExchange: string; 14 | quoteIntervalMs: number; 15 | tifExpiryOffsetMs: number; 16 | markPriceStaleIntervalMs: number; 17 | positionRefreshIntervalMs: number; 18 | riskStatsFetchIntervalMs; 19 | lockingIntervalMs: number; 20 | cashDeltaHedgeThreshold: number; 21 | useHedgeTestnet: boolean; 22 | assets: Object; 23 | } 24 | 25 | export interface SecretsRaw { 26 | makerWallet: number[]; 27 | credentials: Object; 28 | } 29 | 30 | export interface Instrument { 31 | marketIndex: MarketIndex; 32 | levels: { priceIncr: number; quoteCashDelta: number }[]; 33 | } 34 | 35 | export interface AssetParam { 36 | maxZetaCashExposure: number; 37 | maxNetCashDelta: number; 38 | quoteLotSize: number; 39 | widthBps: number; 40 | requoteBps: number; 41 | instruments: Instrument[]; 42 | leanBps: number; 43 | } 44 | 45 | export interface Config { 46 | network: Network; 47 | endpoint: string; 48 | programId: PublicKey; 49 | mintAuthority: Keypair | null; // Not required for mainnet 50 | webServerPort: number; 51 | hedgeExchange: string; 52 | quoteIntervalMs: number; 53 | tifExpiryOffsetMs: number; 54 | markPriceStaleIntervalMs: number; 55 | positionRefreshIntervalMs: number; 56 | riskStatsFetchIntervalMs: number; 57 | lockingIntervalMs: number; 58 | cashDeltaHedgeThreshold: number; 59 | useHedgeTestnet: boolean; 60 | assets: Map; 61 | // from secrets 62 | makerWallet: Keypair; 63 | credentials: Object; 64 | } 65 | 66 | export function loadConfig(): Config { 67 | const config: ConfigRaw = require("../config.json"); 68 | const secrets: SecretsRaw = require("../secrets.json"); 69 | validateSchema(config, require("../config.schema.json")); 70 | validateSchema(secrets, require("../secrets.schema.json")); 71 | const net: Network = network.toNetwork(config.network); 72 | const paramAssets: constants.Asset[] = utils.toAssets( 73 | Object.keys(config.assets) 74 | ); 75 | const programId: PublicKey = new PublicKey(config.programId); 76 | let makerWallet: Keypair = null; 77 | let mintAuthority: Keypair = null; 78 | let assetParams = new Map(); 79 | for (var a of paramAssets) { 80 | assetParams.set(a, config.assets[assets.assetToName(a)]); 81 | } 82 | 83 | try { 84 | makerWallet = Keypair.fromSecretKey(Buffer.from(secrets.makerWallet)); 85 | } catch (e) {} 86 | 87 | try { 88 | mintAuthority = Keypair.fromSecretKey(Buffer.from(config.mintAuthority)); 89 | } catch (e) {} 90 | 91 | const resConfig = { 92 | network: net, 93 | endpoint: config.endpoint, 94 | programId, 95 | mintAuthority, 96 | webServerPort: config.webServerPort, 97 | hedgeExchange: config.hedgeExchange, 98 | quoteIntervalMs: config.quoteIntervalMs, 99 | tifExpiryOffsetMs: config.tifExpiryOffsetMs, 100 | markPriceStaleIntervalMs: config.markPriceStaleIntervalMs, 101 | positionRefreshIntervalMs: config.positionRefreshIntervalMs, 102 | riskStatsFetchIntervalMs: config.riskStatsFetchIntervalMs, 103 | lockingIntervalMs: config.lockingIntervalMs, 104 | cashDeltaHedgeThreshold: config.cashDeltaHedgeThreshold, 105 | useHedgeTestnet: config.useHedgeTestnet, 106 | assets: assetParams, 107 | // from secrets 108 | makerWallet, 109 | credentials: secrets.credentials, 110 | }; 111 | return resConfig; 112 | } 113 | 114 | export function validateSchema(config: any, configSchema: any) { 115 | const ajvInst = new ajv({ strictTuples: false }); 116 | configSchema["$schema"] = undefined; 117 | const validate = ajvInst.compile(configSchema); 118 | const valid = validate(config) as boolean; 119 | if (!valid) 120 | throw new Error( 121 | `Failed to validate config due to errors ${JSON.stringify( 122 | validate.errors 123 | )}` 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { constants, types, CrossClient } from "@zetamarkets/sdk"; 2 | import { BlockhashFetcher } from "./blockhash"; 3 | import { Exchange as ExchangePro, Order } from "ccxt"; 4 | import { Keypair } from "@solana/web3.js"; 5 | 6 | export type Venue = "zeta" | "hedge"; 7 | 8 | export enum MarketIndex { 9 | PERP = constants.PERP_INDEX, 10 | } 11 | 12 | export interface Level { 13 | price: number; 14 | size: number; 15 | } 16 | 17 | export interface TopLevel { 18 | bid: Level; 19 | ask: Level; 20 | } 21 | 22 | export interface TopLevelMsg { 23 | // Should really switch enum to contain string instead of number; 24 | assetName: string; 25 | asset: constants.Asset; 26 | topLevel: TopLevel; 27 | timestamp: number; 28 | } 29 | 30 | interface TickerMsg { 31 | market: string; 32 | type: string; 33 | data: TickerData; 34 | } 35 | 36 | interface TickerData { 37 | bid: number; 38 | ask: number; 39 | bidSize: number; 40 | askSize: number; 41 | time: number; 42 | } 43 | 44 | export function tickerToTopLevelMsg(data: TickerMsg): TopLevelMsg { 45 | return { 46 | assetName: data.market, 47 | asset: marketToAsset(data.market), 48 | topLevel: { 49 | bid: { price: data.data.bid, size: data.data.bidSize }, 50 | ask: { price: data.data.ask, size: data.data.askSize }, 51 | }, 52 | timestamp: data.data.time, 53 | }; 54 | } 55 | 56 | // Hedge exchange (bybit) specific 57 | export function marketToAsset(market: string): constants.Asset { 58 | switch (market) { 59 | case "BTC/USDT:USDT": 60 | return constants.Asset.BTC; 61 | case "SOL/USDT:USDT": 62 | return constants.Asset.SOL; 63 | case "ETH/USDT:USDT": 64 | return constants.Asset.ETH; 65 | case "APT/USDT:USDT": 66 | return constants.Asset.APT; 67 | case "ARB/USDT:USDT": 68 | return constants.Asset.ARB; 69 | } 70 | } 71 | 72 | export function assetToMarket(asset: constants.Asset): string { 73 | switch (asset) { 74 | case constants.Asset.BTC: 75 | return "BTC/USDT:USDT"; 76 | case constants.Asset.ETH: 77 | return "ETH/USDT:USDT"; 78 | case constants.Asset.SOL: 79 | return "SOL/USDT:USDT"; 80 | case constants.Asset.APT: 81 | return "APT/USDT:USDT"; 82 | case constants.Asset.ARB: 83 | return "ARB/USDT:USDT"; 84 | } 85 | } 86 | 87 | export function assetToBinanceMarket(asset: constants.Asset): string { 88 | switch (asset) { 89 | case constants.Asset.BTC: 90 | return "BTC/USDT"; 91 | case constants.Asset.ETH: 92 | return "ETH/USDT"; 93 | case constants.Asset.SOL: 94 | return "SOL/USDT"; 95 | case constants.Asset.APT: 96 | return "APT/USDT"; 97 | case constants.Asset.ARB: 98 | return "ARB/USDT"; 99 | } 100 | } 101 | 102 | // Execution 103 | export interface Spread { 104 | bid: number; 105 | ask: number; 106 | } 107 | 108 | export interface HedgeOrder { 109 | asset: constants.Asset; 110 | market: string; 111 | side: "buy" | "sell"; 112 | price: number; 113 | baseAmount: number; 114 | clientOrderId: string; 115 | } 116 | 117 | export interface Quote { 118 | asset: constants.Asset; 119 | marketIndex: number; 120 | level: number; 121 | side: "bid" | "ask"; 122 | price: number; 123 | size: number; 124 | clientOrderId: number; 125 | } 126 | 127 | export interface Theo { 128 | theo: number; 129 | topBid: Level; 130 | topAsk: Level; 131 | timestamp: number; 132 | } 133 | 134 | export interface PositionNotification { 135 | venue: Venue; 136 | asset: constants.Asset; 137 | marketIndex: MarketIndex; 138 | instrumentDescriptionShort: string; 139 | instrumentDescription: string; 140 | baseSize: number; 141 | cashSize: number; 142 | markPrice: number; 143 | topLevels: { 144 | venue: Venue; 145 | asset: constants.Asset; 146 | base: number; 147 | cash: number; 148 | }[]; 149 | } 150 | 151 | export interface PositionNotificationAgg { 152 | positions: PositionNotification[]; 153 | markPrice?: number; 154 | netCashDelta: number; 155 | netBaseDelta?: number; 156 | } 157 | 158 | export interface ZetaRiskStats { 159 | balance: number; 160 | marginTotal: number; 161 | availableBalanceTotal: number; 162 | pnlTotal: number; 163 | perAsset: Map< 164 | constants.Asset, 165 | { 166 | margin: number; 167 | pnl: number; 168 | } 169 | >; 170 | } 171 | 172 | export interface RiskStats { 173 | balance: number; 174 | margin: number; 175 | availableBalance: number; 176 | pnl: number; 177 | } 178 | 179 | export interface QuoteBreach { 180 | type: "net" | "zeta"; 181 | rejectedQuoteTypes: "bid" | "ask"; 182 | cash: number; 183 | limit: number; 184 | asset: constants.Asset; 185 | marketIndex?: MarketIndex; 186 | } 187 | 188 | export interface DashboardState { 189 | getZetaOrders(): any; 190 | getPosition( 191 | venue: Venue, 192 | asset?: constants.Asset, 193 | index?: MarketIndex 194 | ): PositionNotificationAgg; 195 | 196 | getTheo(asset: constants.Asset): Theo; 197 | 198 | getRiskStats(venue: Venue): RiskStats | ZetaRiskStats; 199 | 200 | getQuoteBreaches(): QuoteBreach[]; 201 | } 202 | 203 | export interface ITrader { 204 | initialize( 205 | assets: constants.Asset[], 206 | blockhashFetcher: BlockhashFetcher, 207 | zetaClient: CrossClient, 208 | hedgeExchange: ExchangePro 209 | ); 210 | sendHedgeOrders(orders: HedgeOrder[]): Promise; 211 | sendZetaQuotes(quotes: Quote[]); 212 | shutdown(); 213 | getHedgeOrders( 214 | asset: constants.Asset, 215 | linkOrderIds?: string[] 216 | ): Promise; 217 | } 218 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assets, 3 | Exchange, 4 | constants, 5 | CrossClient, 6 | types, 7 | } from "@zetamarkets/sdk"; 8 | import { PublicKey } from "@solana/web3.js"; 9 | import { BPS_FACTOR } from "./constants"; 10 | import { log } from "./log"; 11 | import { MarketIndex, RiskStats } from "./types"; 12 | import fs from "fs"; 13 | import { parse } from "csv-parse"; 14 | import { finished } from "stream/promises"; 15 | 16 | export async function initializeClientState(zetaClient: CrossClient) { 17 | if (!zetaClient.account) { 18 | throw Error("cross margin account doesn't exist"); 19 | } 20 | 21 | for (let i = 0; i < zetaClient.openOrdersAccounts.length; i++) { 22 | if (zetaClient.openOrdersAccounts[i].equals(PublicKey.default)) { 23 | log.debug( 24 | `Creating open orders account for ${assets.indexToAsset( 25 | i 26 | )} : Index: ${i}` 27 | ); 28 | 29 | await zetaClient.initializeOpenOrdersAccount(assets.indexToAsset(i)); 30 | } 31 | } 32 | } 33 | 34 | export function marketIndexToName( 35 | asset: constants.Asset, 36 | index: number 37 | ): string { 38 | if (index == constants.PERP_INDEX) { 39 | return "PERP"; 40 | } 41 | } 42 | 43 | export function toDDMMMYY(date: Date): string { 44 | const months = [ 45 | `JAN`, 46 | `FEB`, 47 | `MAR`, 48 | `APR`, 49 | `MAY`, 50 | `JUN`, 51 | `JUL`, 52 | `AUG`, 53 | `SEP`, 54 | `OCT`, 55 | `NOV`, 56 | `DEC`, 57 | ]; 58 | let dd = date.getDate() >= 10 ? `${date.getDate()}` : `0${date.getDate()}`; 59 | let mmm = months[date.getMonth()]; 60 | let yy = `${date.getFullYear()}`.slice(2); 61 | return `${dd}${mmm}${yy}`; 62 | } 63 | 64 | export function isValidVenue(asStr: string): boolean { 65 | return asStr == "zeta" || asStr == "hedge"; 66 | } 67 | 68 | export function schedule(closure: () => void, interval: number): NodeJS.Timer { 69 | // call first time 70 | (async () => { 71 | closure(); 72 | })(); 73 | // schedule subsequent 74 | return setInterval(closure, interval); 75 | } 76 | 77 | export function diffInBps(x, y: number): number { 78 | let diff = Math.abs(x - y); 79 | return (diff / y) * BPS_FACTOR; 80 | } 81 | 82 | export function unique(ts: T[]): T[] { 83 | return Array.from(new Set(ts)); 84 | } 85 | 86 | export function groupBy(ts: T[], getKey: (t: T) => K): [K, T[]][] { 87 | const grouped = new Map(); 88 | for (var t of ts) { 89 | const key = getKey(t); 90 | let forKey = grouped.get(key); 91 | if (!forKey) grouped.set(key, [t]); 92 | else forKey.push(t); 93 | } 94 | return Array.from(grouped.entries()); 95 | } 96 | 97 | export function emptyRiskStats(): RiskStats { 98 | return { 99 | balance: 0, 100 | margin: 0, 101 | availableBalance: 0, 102 | pnl: 0, 103 | }; 104 | } 105 | 106 | export function sleep(ms: number) { 107 | return new Promise((resolve) => setTimeout(resolve, ms)); 108 | } 109 | 110 | // helper for getting description of product based on asset & marketIndex 111 | export function instrumentDescription( 112 | asset: constants.Asset, 113 | index?: number 114 | ): string { 115 | if (index == undefined) { 116 | return asset; 117 | } else { 118 | return `${asset} PERP`; 119 | } 120 | } 121 | 122 | export function marketIndexShortDescription(index: MarketIndex): string { 123 | return index == MarketIndex.PERP ? "PERP" : undefined; 124 | } 125 | 126 | export function toFixed(x: number, fractionDigits: number): string { 127 | if (x != undefined) { 128 | const fixed = x.toFixed(fractionDigits); 129 | const asStr = fractionDigits < 1 ? fixed : fixed.replace(/\.*0+$/, ""); 130 | return asStr == "-0" ? "0" : asStr; // happens when rounded down to 0, but is small negative fraction 131 | } 132 | } 133 | 134 | export function capFirst(str: string): string { 135 | if (str && str.length > 0) return str.charAt(0).toUpperCase() + str.slice(1); 136 | } 137 | 138 | // generates ids based on current timestamp. 139 | // NOTE: assumes only 1 creator to exist at the time, and the rate of id generation to be > 1/ms (in case creator is re-initialized) 140 | // based on Atomics: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/add 141 | export function idCreator(start?: number): () => number { 142 | const buffer = new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT); 143 | const uint32 = new BigInt64Array(buffer); 144 | uint32[0] = BigInt(start ?? Date.now()); 145 | function createId(): number { 146 | return Number(Atomics.add(uint32, 0, 1n)); 147 | } 148 | return createId; 149 | } 150 | 151 | export function bumpRestartCount(): number { 152 | const restartsCntFilename = "restart-cnt.txt"; 153 | let restartCnt = 0; 154 | try { 155 | restartCnt = +fs.readFileSync(restartsCntFilename); 156 | } catch (e) {} 157 | restartCnt += 1; 158 | fs.writeFileSync(restartsCntFilename, "" + restartCnt); 159 | return restartCnt; 160 | } 161 | 162 | interface BalanceCSV { 163 | unixTs: number; 164 | balance: number; 165 | } 166 | 167 | export async function readCSV(filename: string): Promise { 168 | const fc = fs.readFileSync(filename); 169 | const headers = ["unixTs", "balance"]; 170 | 171 | let csvContents: BalanceCSV[] = []; 172 | await finished( 173 | parse( 174 | fc, 175 | { 176 | delimiter: ",", 177 | columns: headers, 178 | }, 179 | (error, result: BalanceCSV[]) => { 180 | if (error) { 181 | throw error; 182 | } 183 | 184 | csvContents = result; 185 | } 186 | ) 187 | ); 188 | return csvContents.slice(1); 189 | } 190 | 191 | export async function appendToCSV(filename: string, balance: string) { 192 | fs.appendFileSync(filename, `${Math.round(Date.now() / 1000)}, ${balance}\n`); 193 | } 194 | 195 | export function convertToReadableOrders( 196 | m: Map 197 | ): any { 198 | let readableObj: any = {}; 199 | for (let [asset, orders] of m) { 200 | let readableOrders = []; 201 | for (let i = 0; i < orders.length; i++) { 202 | readableOrders.push({ 203 | price: orders[i].price, 204 | size: orders[i].size, 205 | side: orders[i].side == types.Side.BID ? "bid" : "ask", 206 | }); 207 | } 208 | readableObj[assets.assetToName(asset)] = readableOrders; 209 | } 210 | return readableObj; 211 | } 212 | -------------------------------------------------------------------------------- /src/webserver.ts: -------------------------------------------------------------------------------- 1 | import { assets, constants, Network } from "@zetamarkets/sdk"; 2 | import express from "express"; 3 | import hbs from "hbs"; 4 | import { log } from "./log"; 5 | import { DashboardState, RiskStats, Venue, ZetaRiskStats } from "./types"; 6 | import { 7 | isValidVenue, 8 | marketIndexShortDescription, 9 | toFixed, 10 | readCSV, 11 | } from "./utils"; 12 | 13 | export function startExpress( 14 | port: number, 15 | cashDeltaHedgeThreshold: number, 16 | allAssets: constants.Asset[], 17 | network: Network, 18 | restartCnt: number, 19 | dashboardState: DashboardState 20 | ) { 21 | function renderPosition( 22 | venue: Venue, 23 | asset: constants.Asset, 24 | index: number, 25 | res 26 | ) { 27 | const position = dashboardState.getPosition(venue, asset, index); 28 | if (position == undefined) res.sendStatus(404); 29 | else 30 | res 31 | .setHeader(`Content-Type`, `application/json`) 32 | .send(JSON.stringify(position, undefined, 4)); 33 | } 34 | 35 | function renderZetaOrders(res) { 36 | const orders = dashboardState.getZetaOrders(); 37 | res 38 | .setHeader(`Content-Type`, `application/json`) 39 | .send(JSON.stringify(orders[0], undefined, 4)); 40 | } 41 | 42 | hbs.handlebars.registerHelper("currency", function (cash: number) { 43 | return `$${toFixed(cash, 2)}`; 44 | }); 45 | hbs.handlebars.registerHelper("baseAmount", function (cash: number) { 46 | return `${toFixed(cash, 5)}`; 47 | }); 48 | hbs.handlebars.registerHelper( 49 | "currencyDeltaStyle", 50 | function (cashDelta: number) { 51 | return Math.abs(cashDelta) > cashDeltaHedgeThreshold 52 | ? `cashDeltaWarning` 53 | : `cashDeltaOk`; 54 | } 55 | ); 56 | const expressApp = express(); 57 | expressApp.use(express.json()); 58 | expressApp.set("view engine", "hbs"); 59 | 60 | expressApp.get(`/position/:venue`, (req, res) => { 61 | const venue = req.params.venue; 62 | if (isValidVenue(venue)) renderPosition(venue, undefined, undefined, res); 63 | else res.sendStatus(400); 64 | }); 65 | 66 | expressApp.get(`/position/:venue/:asset`, (req, res) => { 67 | const venue = req.params.venue; 68 | const asset = req.params.asset; 69 | if (isValidVenue(venue) && assets.isValidStr(asset)) 70 | renderPosition(venue, asset, undefined, res); 71 | else res.sendStatus(400); 72 | }); 73 | 74 | expressApp.get(`/position/:venue/:asset/:index`, (req, res) => { 75 | const venue = req.params.venue; 76 | const asset = req.params.asset; 77 | const index = +req.params.index; 78 | if (isValidVenue(venue) && assets.isValidStr(asset)) 79 | renderPosition(venue, asset, index, res); 80 | else res.sendStatus(400); 81 | }); 82 | 83 | expressApp.get(`/restart`, (_req, res) => { 84 | res 85 | .setHeader(`Content-Type`, `application/json`) 86 | .send(`{ "restartCnt": ${restartCnt} }`); 87 | }); 88 | 89 | expressApp.get(`/orders`, (_req, res) => { 90 | renderZetaOrders(res); 91 | }); 92 | 93 | expressApp.get("/dashboard", async (req, res) => { 94 | const refresh = req.query.refresh ?? 10; 95 | const zetaPositionAgg = dashboardState.getPosition("zeta"); 96 | const hedgePositionAgg = dashboardState.getPosition("hedge"); 97 | const theos = allAssets.map((asset) => dashboardState.getTheo(asset)); 98 | 99 | const zetaBalanceValues = await readCSV("balances/zeta-balance.csv"); 100 | const hedgeBalanceValues = await readCSV("balances/hedge-balance.csv"); 101 | const totalBalanceValues = await readCSV("balances/total-balance.csv"); 102 | let toGraphZTs = []; 103 | let toGraphZ = []; 104 | let toGraphHTs = []; 105 | let toGraphH = []; 106 | let toGraphTTs = []; 107 | let toGraphT = []; 108 | 109 | zetaBalanceValues.forEach((v) => { 110 | toGraphZTs.push(Number(v.unixTs)); 111 | toGraphZ.push(Number(v.balance)); 112 | }); 113 | hedgeBalanceValues.forEach((v) => { 114 | toGraphHTs.push(Number(v.unixTs)); 115 | toGraphH.push(Number(v.balance)); 116 | }); 117 | totalBalanceValues.forEach((v) => { 118 | toGraphTTs.push(Number(v.unixTs)); 119 | toGraphT.push(Number(v.balance)); 120 | }); 121 | 122 | const rs = dashboardState.getRiskStats("zeta") as ZetaRiskStats; 123 | 124 | let riskStats = [ 125 | { 126 | balance: rs.balance, 127 | margin: rs.marginTotal, 128 | availableBalance: rs.availableBalanceTotal, 129 | pnl: rs.pnlTotal, 130 | venue: "zeta", 131 | asset: "---", 132 | }, 133 | ...allAssets.map((asset) => { 134 | return { 135 | balance: 0, 136 | margin: rs.perAsset.get(asset).margin, 137 | availableBalance: 0, 138 | pnl: rs.perAsset.get(asset).pnl, 139 | venue: "zeta", 140 | asset, 141 | }; 142 | }), 143 | { 144 | ...(dashboardState.getRiskStats("hedge") as RiskStats), 145 | venue: "hedge", 146 | asset: "---", 147 | }, 148 | ]; 149 | 150 | res.render("dashboard", { 151 | now: new Date().toLocaleString(), 152 | 153 | refresh, 154 | assets: allAssets, 155 | // zeta 156 | zetaInstruments: [constants.PERP_INDEX].map((ind) => { 157 | let totalCash = 0; 158 | const prices = allAssets.map((asset) => { 159 | const pos = dashboardState.getPosition("zeta", asset, ind); 160 | totalCash += pos?.netCashDelta ?? 0; 161 | return { base: pos?.netBaseDelta ?? 0, cash: pos?.netCashDelta ?? 0 }; 162 | }); 163 | return { 164 | instrumentName: marketIndexShortDescription(ind), 165 | prices, 166 | totalCash, 167 | }; 168 | }), 169 | zetaTotals: allAssets.map((a) => { 170 | const pos = dashboardState.getPosition("zeta", a); 171 | return { base: pos?.netBaseDelta ?? 0, cash: pos?.netCashDelta ?? 0 }; 172 | }), 173 | zetaTotalCash: zetaPositionAgg?.netCashDelta ?? 0, 174 | 175 | // hedge 176 | hedgeTotals: allAssets.map((a) => { 177 | const pos = dashboardState.getPosition("hedge", a); 178 | return { base: pos?.netBaseDelta ?? 0, cash: pos?.netCashDelta ?? 0 }; 179 | }), 180 | hedgeTotalCash: hedgePositionAgg?.netCashDelta ?? 0, 181 | 182 | // netCashDeltas 183 | netCashDeltas: allAssets.map( 184 | (a) => 185 | (dashboardState.getPosition("zeta", a)?.netCashDelta ?? 0) + 186 | (dashboardState.getPosition("hedge", a)?.netCashDelta ?? 0) 187 | ), 188 | totalNetCashDelta: 189 | (zetaPositionAgg?.netCashDelta ?? 0) + 190 | (hedgePositionAgg?.netCashDelta ?? 0), 191 | 192 | theos, 193 | theosTs: theos.map((x) => new Date(x?.timestamp).toLocaleString()), 194 | 195 | riskStats, 196 | 197 | quoteBreaches: dashboardState 198 | .getQuoteBreaches() 199 | .map( 200 | (x) => 201 | `${x.type} ${x.asset}${ 202 | x.marketIndex 203 | ? `-${marketIndexShortDescription(x.marketIndex)}` 204 | : "" 205 | } rejects ${x.rejectedQuoteTypes} due to cash ${x.cash} vs limit ${ 206 | x.limit 207 | }` 208 | ), 209 | 210 | network, 211 | restartCnt, 212 | zetaBalanceToGraphX: JSON.stringify(toGraphZTs), 213 | zetaBalanceToGraphY: JSON.stringify(toGraphZ), 214 | hedgeBalanceToGraphX: JSON.stringify(toGraphHTs), 215 | hedgeBalanceToGraphY: JSON.stringify(toGraphH), 216 | balanceToGraphX: JSON.stringify(toGraphTTs), 217 | balanceToGraphY: JSON.stringify(toGraphT), 218 | }); 219 | }); 220 | 221 | expressApp.listen(port, () => 222 | log.info(`Express server started at port ${port}`) 223 | ); 224 | } 225 | -------------------------------------------------------------------------------- /views/dashboard.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zeta vs Hedge Positions 6 | 14 | 15 | 16 |

Zeta

17 | 18 | {{#each assets}}{{/each}} 19 | {{#each zetaInstruments}} 20 | {{#each prices}}{{/each}} 21 | {{/each}} 22 | {{#each zetaTotals}}{{/each}} 23 |
{{this}}Total
{{instrumentName}}
{{baseAmount base}}
/
{{currency cash}}
{{currency totalCash}}
Total
{{baseAmount base}}
/
{{currency cash}}
{{currency zetaTotalCash}}
24 | 25 |

Hedge

26 | 27 | {{#each assets}}{{/each}} 28 | {{#each hedgeTotals}}{{/each}} 29 |
{{this}}Total
Total
{{baseAmount base}}
/
{{currency cash}}
{{currency hedgeTotalCash}}
30 | 31 |

Net cash delta: Zeta cash + Hedge cash

32 | 33 | {{#each assets}}{{/each}} 34 | {{#each netCashDeltas}}{{/each}} 35 |
{{this}}Total
Total
{{currency this}}
{{currency totalNetCashDelta}}
36 | 37 |

Risk

38 | 39 | 40 | {{#each riskStats}}{{/each}} 41 |
VenueAssetBalanceMarginAvail BalancePnL
{{venue}}{{asset}}{{currency balance}}{{currency margin}}{{currency availableBalance}}{{currency pnl}}
42 | 43 |

Prices

44 | 45 | {{#each assets}}{{/each}} 46 | {{#each theos}}{{/each}} 47 | {{#each theosTs}}{{/each}} 48 |
{{this}}
Mark
{{currency theo}}
Updated
{{this}}
49 | 50 |

Quote breaches

51 | 52 | {{#each quoteBreaches}}{{/each}} 53 |
{{this}}
54 | 55 |

Zeta balance over time

56 | 57 | 58 | 59 | 60 | 103 | 104 | 105 | 106 | 107 |

Hedge balance over time

108 | 109 | 110 | 111 | 112 | 155 | 156 | 157 | 158 | 159 |

Total balance over time

160 | 161 | 162 | 163 | 164 | 207 | 208 | 209 | 210 | 211 |

212 | *Network: {{network}} 213 |

214 | *Restart: {{restartCnt}} 215 |

216 | *Legend: numbers either in (base/cash) or cash 217 |

218 | *Timestamped @ {{now}}, refreshed every {{refresh}}s 219 | 220 | 221 | -------------------------------------------------------------------------------- /src/trader.ts: -------------------------------------------------------------------------------- 1 | import { Keypair, Transaction } from "@solana/web3.js"; 2 | import { 3 | CrossClient, 4 | constants, 5 | Exchange, 6 | types, 7 | utils, 8 | } from "@zetamarkets/sdk"; 9 | import { Exchange as ExchangePro, Order } from "ccxt"; 10 | import { BlockhashFetcher } from "./blockhash"; 11 | import { convertPriceToOrderPrice } from "./math"; 12 | import { assetToMarket, HedgeOrder, Quote } from "./types"; 13 | import { groupBy, toFixed } from "./utils"; 14 | import { log } from "./log"; 15 | 16 | export class Trader { 17 | private assets: constants.Asset[]; 18 | private zetaClient: CrossClient; 19 | 20 | private hedgeExchange: ExchangePro; 21 | private blockhashFetcher: BlockhashFetcher; 22 | private isInitialized: boolean = false; 23 | private isShuttingDown: boolean = false; 24 | private tifExpiryOffsetSecs: number; 25 | 26 | constructor(tifExpiryOffsetSecs: number) { 27 | this.tifExpiryOffsetSecs = tifExpiryOffsetSecs; 28 | } 29 | 30 | async initialize( 31 | assets: constants.Asset[], 32 | blockhashFetcher: BlockhashFetcher, 33 | zetaClient: CrossClient, 34 | 35 | hedgeExchange: ExchangePro 36 | ) { 37 | this.assets = assets; 38 | this.blockhashFetcher = blockhashFetcher; 39 | this.zetaClient = zetaClient; 40 | 41 | this.hedgeExchange = hedgeExchange; 42 | blockhashFetcher.subscribe(); 43 | this.isInitialized = true; 44 | } 45 | 46 | async sendHedgeOrders(orders: HedgeOrder[]): Promise { 47 | if (!this.isInitialized || this.isShuttingDown) { 48 | log.warn( 49 | `Trader not yet initialized or shutting down, ignoring sendHedgeOrders()` 50 | ); 51 | return; 52 | } 53 | 54 | return Promise.all( 55 | orders.map(async (order) => { 56 | log.info( 57 | `Issuing hedge order: ${order.market} ${order.side} ${toFixed( 58 | order.baseAmount, 59 | 5 60 | )} @ ${toFixed(order.price, 5)}` 61 | ); 62 | await this.hedgeExchange.createLimitOrder( 63 | order.market, 64 | order.side, 65 | order.baseAmount, 66 | order.price, 67 | { 68 | timeInForce: `IOC`, 69 | order_link_id: order.clientOrderId, 70 | // leverage: 1, 71 | // position_idx as per https://medium.com/superalgos/superalgos-goes-perps-on-bybit-5dc2be9a59a7 72 | position_idx: 0, 73 | } 74 | ); 75 | return order.clientOrderId; 76 | }) 77 | ); 78 | } 79 | 80 | async getHedgeOrders( 81 | asset: constants.Asset, 82 | linkOrderIds?: string[] 83 | ): Promise { 84 | if (!this.isInitialized || this.isShuttingDown) { 85 | log.warn( 86 | `Trader not yet initialized or shutting down, ignoring getHedgeOrders()` 87 | ); 88 | return; 89 | } 90 | 91 | const market = assetToMarket(asset); 92 | const orders = await this.hedgeExchange.fetchOrders(market); 93 | if (linkOrderIds) 94 | return orders.filter((x) => linkOrderIds.includes(x.clientOrderId)); 95 | else return orders; 96 | } 97 | 98 | async sendZetaQuotes(quotes: Quote[]) { 99 | if (!this.isInitialized || this.isShuttingDown) { 100 | log.warn( 101 | `Trader not yet initialized or shutting down, ignoring sendZetaQuotes()` 102 | ); 103 | return; 104 | } 105 | 106 | // group by asset, marketIndex, level 107 | const viableQuotes = quotes.filter( 108 | (q) => 109 | q.marketIndex == constants.PERP_INDEX || 110 | Exchange.getSubExchange(q.asset).markets.markets[ 111 | q.marketIndex 112 | ].expirySeries.isLive() 113 | ); 114 | const quoteByAsset = groupBy( 115 | viableQuotes, 116 | (q) => `${q.asset}-${q.marketIndex}` 117 | ); 118 | 119 | await Promise.all( 120 | quoteByAsset.map(async ([_key, quotes]) => { 121 | await this.sendZetaQuotesForAsset(quotes[0].asset, quotes); 122 | }) 123 | ); 124 | } 125 | 126 | private async sendZetaQuotesForAsset(asset: constants.Asset, msgs: Quote[]) { 127 | // sort by level to ensure level0 cancellations are preformed first 128 | const levelSorted = groupBy(msgs, (msg) => msg.level).sort( 129 | ([l1, _q1], [l2, _q2]) => l1 - l2 130 | ); 131 | 132 | let orderStr = ""; 133 | 134 | let clientToUse = this.zetaClient; 135 | 136 | let atomicCancelAndPlaceTx = new Transaction().add( 137 | clientToUse.createCancelAllMarketOrdersInstruction(asset) 138 | ); 139 | for (var [level, quotes] of levelSorted) { 140 | for (var quote of quotes.filter(({ size }) => size > 0)) { 141 | const price = convertPriceToOrderPrice(quote.price, true); 142 | const size = utils.convertDecimalToNativeLotSize(quote.size); 143 | const marketIndex = quote.marketIndex; 144 | const side = quote.side; 145 | const clientOrderId = quote.clientOrderId; 146 | 147 | let orderOptions: types.OrderOptions = { 148 | // Note: switching to wall clock expiryTs, as means to mitigate CannotPlaceExpiredOrder errors 149 | // tifOptions: { expiryOffset: this.tifExpiryOffsetSecs }, 150 | orderType: types.OrderType.POSTONLYSLIDE, 151 | tifOptions: { 152 | expiryTs: Date.now() / 1000 + this.tifExpiryOffsetSecs, 153 | }, 154 | clientOrderId, 155 | }; 156 | 157 | atomicCancelAndPlaceTx.add( 158 | clientToUse.createPlacePerpOrderInstruction( 159 | asset, 160 | price, 161 | size, 162 | side == "bid" ? types.Side.BID : types.Side.ASK, 163 | orderOptions 164 | ) 165 | ); 166 | 167 | const priceAsDecimal = utils.convertNativeIntegerToDecimal(price); 168 | orderStr = orderStr.concat( 169 | `\n[${side.toUpperCase()}] ${asset} index ${marketIndex}, level ${level}, ${utils.convertNativeLotSizeToDecimal( 170 | size 171 | )} lots @ $${priceAsDecimal}, ts-delta ${ 172 | Date.now() / 1000 - Exchange.clockTimestamp 173 | }` 174 | ); 175 | } 176 | } 177 | 178 | // execute txs with fallback of cancellation 179 | let blockhash = this.blockhashFetcher.blockhash; 180 | 181 | try { 182 | await utils.processTransaction( 183 | clientToUse.provider, 184 | atomicCancelAndPlaceTx, 185 | undefined, 186 | undefined, 187 | undefined, 188 | utils.getZetaLutArr(), 189 | blockhash 190 | ); 191 | log.debug( 192 | `Sent new zeta quotes for ${asset} marketIndices-levels ${msgs 193 | .map((x) => `${x.marketIndex}-${x.level}`) 194 | .join(", ")}!` 195 | ); 196 | } catch (e) { 197 | log.warn( 198 | `Asset: ${asset}, Failed to send txns on blockhash ${blockhash}`, 199 | e 200 | ); 201 | } 202 | } 203 | 204 | async shutdown() { 205 | if (!this.isInitialized || this.isShuttingDown) { 206 | log.warn( 207 | `Trader not yet initialized or shutting down, ignoring shutdown()` 208 | ); 209 | return; 210 | } 211 | 212 | this.isShuttingDown = true; 213 | try { 214 | let retry = 0; 215 | while (retry < 10) { 216 | try { 217 | await this.zetaClient.updateState(); 218 | const totalOrders = this.assets 219 | .map((asset) => this.zetaClient.orders.get(asset).length) 220 | .reduce((x, y) => x + y, 0); 221 | if (totalOrders > 0) { 222 | log.info( 223 | `About to cancel ${totalOrders} zeta orders for assets ${this.assets.join( 224 | ", " 225 | )}` 226 | ); 227 | await this.zetaClient.cancelAllOrders(); 228 | } else { 229 | log.info(`Cancelled all zeta orders`); 230 | break; 231 | } 232 | } catch (e) { 233 | retry++; 234 | log.info(`Zeta shutdown error ${e}, retry ${retry}...`); 235 | } 236 | } 237 | } finally { 238 | this.zetaClient.close(); 239 | } 240 | 241 | for (var asset of this.assets) 242 | await this.hedgeExchange.cancelAllOrders(assetToMarket(asset)); 243 | this.blockhashFetcher.shutdown(); 244 | } 245 | } 246 | 247 | export class PermissionedTrader { 248 | private trader: Trader; 249 | 250 | constructor(tifExpiryOffsetSecs: number) { 251 | this.trader = new Trader(tifExpiryOffsetSecs); 252 | } 253 | 254 | async initialize( 255 | assets: constants.Asset[], 256 | blockhashFetcher: BlockhashFetcher, 257 | zetaClient: CrossClient, 258 | hedgeExchange: ExchangePro 259 | ) { 260 | await this.trader.initialize( 261 | assets, 262 | blockhashFetcher, 263 | zetaClient, 264 | hedgeExchange 265 | ); 266 | } 267 | 268 | async sendHedgeOrders(orders: HedgeOrder[]): Promise { 269 | return await this.trader.sendHedgeOrders(orders); 270 | } 271 | 272 | async sendZetaQuotes(quotes: Quote[]) { 273 | await this.trader.sendZetaQuotes(quotes); 274 | } 275 | 276 | async shutdown() { 277 | await this.trader.shutdown(); 278 | } 279 | 280 | async getHedgeOrders( 281 | asset: constants.Asset, 282 | linkOrderIds?: string[] 283 | ): Promise { 284 | return await this.trader.getHedgeOrders(asset, linkOrderIds); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { constants } from "@zetamarkets/sdk"; 2 | import { 3 | Theo, 4 | TopLevelMsg, 5 | PositionNotificationAgg, 6 | Venue, 7 | HedgeOrder, 8 | PositionNotification, 9 | assetToMarket, 10 | MarketIndex, 11 | Quote, 12 | QuoteBreach, 13 | } from "./types"; 14 | import { roundLotSize, calculateFair, calculateSpread } from "./math"; 15 | import { PositionAgg } from "./position-agg"; 16 | import { AssetParam, Instrument } from "./configuration"; 17 | import { diffInBps, marketIndexShortDescription, toFixed } from "./utils"; 18 | import { log } from "./log"; 19 | 20 | export interface Effects { 21 | hedgeOrders: HedgeOrder[]; 22 | zetaPositionNotifications: PositionNotification[]; 23 | hedgePositionNotifications: PositionNotification[]; 24 | } 25 | 26 | function emptyEffects(): Effects { 27 | return { 28 | hedgeOrders: [], 29 | zetaPositionNotifications: [], 30 | hedgePositionNotifications: [], 31 | }; 32 | } 33 | 34 | export interface QuotingEffects { 35 | quotes: Quote[]; 36 | breaches: QuoteBreach[]; 37 | } 38 | 39 | export class State { 40 | private cashDeltaHedgeThreshold: number; 41 | private assetParams: Map; 42 | private quoting: Map = new Map(); 43 | private theos: Map = new Map(); 44 | private funding: Map = new Map(); 45 | private positionAgg: PositionAgg = new PositionAgg(); 46 | private getInstrumentDescription: (a: constants.Asset, i?: number) => string; 47 | private createClientId: () => number; 48 | 49 | constructor( 50 | cashDeltaHedgeThreshold: number, 51 | assetParams: Map, 52 | getInstrumentDescription: ( 53 | asset: constants.Asset, 54 | index?: number 55 | ) => string, 56 | createClientId: () => number 57 | ) { 58 | this.cashDeltaHedgeThreshold = cashDeltaHedgeThreshold; 59 | this.assetParams = assetParams; 60 | this.getInstrumentDescription = getInstrumentDescription; 61 | this.createClientId = createClientId; 62 | } 63 | 64 | getCurrentQuotes(asset: constants.Asset): QuotingEffects { 65 | return this.quoting.get(asset); 66 | } 67 | 68 | calcNewQuotes(asset: constants.Asset): QuotingEffects { 69 | const { quotes, breaches } = this.calcQuotes( 70 | asset, 71 | this.assetParams.get(asset).instruments, 72 | this.theos.get(asset) 73 | ); 74 | 75 | this.quoting.set(asset, { quotes, breaches }); 76 | return { quotes, breaches }; 77 | } 78 | 79 | setMarkPriceUpdate(msg: TopLevelMsg, timestamp: number) { 80 | const newTheo = { 81 | theo: calculateFair(msg), 82 | topBid: msg.topLevel.bid, 83 | topAsk: msg.topLevel.ask, 84 | timestamp, 85 | }; 86 | this.theos.set(msg.asset, newTheo); 87 | } 88 | 89 | calcQuoting(asset: constants.Asset): QuotingEffects { 90 | const { quotes, breaches } = this.calcQuotes( 91 | asset, 92 | this.assetParams.get(asset).instruments, 93 | this.theos.get(asset) 94 | ); 95 | 96 | // compare price movements, re-issue only if exceeds requoteBps 97 | const quoting = this.quoting.get(asset); 98 | if (!quoting) { 99 | log.info( 100 | `Will issue new zeta quotes for ${asset} ${quotes 101 | .map((x) => `\n- ${JSON.stringify(x)}`) 102 | .join("")}` 103 | ); 104 | this.quoting.set(asset, { quotes, breaches }); 105 | return { quotes, breaches }; 106 | } else { 107 | // if find any diffs with desiredQuotes, add re-issue all quotes for this asset 108 | // FIXME: optimize to re-issue just the changed quotes 109 | const changes: string[] = []; 110 | 111 | const requoteBps = this.assetParams.get(asset).requoteBps; 112 | if (quoting.quotes.length != quotes.length) 113 | changes.push( 114 | `- change in quote counts:${quoting.quotes.length} => ${quotes.length}` 115 | ); 116 | for (var quote of quotes) { 117 | const expQuote = quoting.quotes.find( 118 | (x) => 119 | x.asset == quote.asset && 120 | x.marketIndex == quote.marketIndex && 121 | x.level == quote.level && 122 | x.side == quote.side 123 | ); 124 | if (expQuote) { 125 | const priceMovementBps = diffInBps(expQuote.price, quote.price); 126 | if (priceMovementBps > requoteBps) 127 | changes.push( 128 | `- asset ${quote.asset}, side: ${quote.side}, marketIndex: ${ 129 | quote.marketIndex 130 | }, level: ${quote.level}, size: ${expQuote.size} => ${ 131 | quote.size 132 | }, price: ${toFixed(expQuote.price, 2)} => ${toFixed( 133 | quote.price, 134 | 2 135 | )} (moved ${toFixed(priceMovementBps, 5)}bps), clientOrderId: ${ 136 | quote.clientOrderId 137 | }` 138 | ); 139 | } else { 140 | changes.push( 141 | `- non-existing quote asset ${quote.asset}, side: ${quote.side}, marketIndex: ${quote.marketIndex}, level: ${quote.level}, size: ${quote.size}, clientOrderId: ${quote.clientOrderId}` 142 | ); 143 | } 144 | } 145 | 146 | if (changes.length > 0) { 147 | log.info( 148 | `Will issue zeta quotes for ${asset} due to price(/size) changes:\n${changes.join( 149 | "\n" 150 | )}` 151 | ); 152 | this.quoting.set(asset, { quotes, breaches }); 153 | return { quotes, breaches }; 154 | } else { 155 | log.debug( 156 | `No quotes issued for ${JSON.stringify( 157 | asset 158 | )}, no changes compared with existing desired quotes` 159 | ); 160 | return { quotes: [], breaches }; 161 | } 162 | } 163 | } 164 | 165 | setPositionUpdate( 166 | venue: Venue, 167 | asset: constants.Asset, 168 | marketIndex: number, 169 | size: number, 170 | forceRun: boolean = false 171 | ): Effects { 172 | const prevSize = 173 | this.positionAgg.sum({ 174 | venue, 175 | asset, 176 | marketIndex, 177 | }) ?? 0; 178 | if (!forceRun && size == prevSize) return emptyEffects(); // no change, early exit 179 | 180 | this.positionAgg.set({ venue, asset, marketIndex }, size); 181 | const zetaAssetBaseSize = this.positionAgg.sum({ venue: "zeta", asset }); 182 | const hedgeAssetBaseSize = this.positionAgg.sum({ venue: "hedge", asset }); 183 | const theo = this.theos.get(asset); 184 | 185 | let hedgeOrders: HedgeOrder[] = []; 186 | let zetaPositionNotifications: PositionNotification[] = []; 187 | let hedgePositionNotifications: PositionNotification[] = []; 188 | 189 | if ( 190 | theo != undefined && 191 | zetaAssetBaseSize != undefined && 192 | hedgeAssetBaseSize != undefined 193 | ) { 194 | const baseDelta = hedgeAssetBaseSize + zetaAssetBaseSize; 195 | const absBaseDelta = Math.abs(baseDelta); 196 | const absCashDelta = absBaseDelta * theo.theo; 197 | 198 | if (absCashDelta > this.cashDeltaHedgeThreshold) { 199 | const side = baseDelta > 0 ? "sell" : "buy"; 200 | const price = side == "buy" ? theo.topAsk.price : theo.topBid.price; 201 | hedgeOrders = [ 202 | { 203 | asset, 204 | market: assetToMarket(asset), 205 | side, 206 | price, 207 | baseAmount: absBaseDelta, 208 | clientOrderId: "" + this.createClientId(), 209 | }, 210 | ]; 211 | log.info( 212 | `Will send ${asset} hedge order due to absCashDelta ${toFixed( 213 | absCashDelta, 214 | 2 215 | )} > cashDeltaHedgeThreshold ${ 216 | this.cashDeltaHedgeThreshold 217 | }: ${side} ${absBaseDelta} (zeta ${zetaAssetBaseSize} - hedge ${hedgeAssetBaseSize}) @ ${price}, trigger position change: ${venue} ${asset}-${marketIndex}: ${size} @${ 218 | theo.theo 219 | }, forceRun: ${forceRun}` 220 | ); 221 | } else 222 | log.debug( 223 | `No ${asset} hedge orders issued due to absCashDelta ${toFixed( 224 | absCashDelta, 225 | 2 226 | )} (base zeta ${zetaAssetBaseSize} - hedge ${hedgeAssetBaseSize}, @ ${toFixed( 227 | theo.theo, 228 | 2 229 | )}) <= cashDeltaHedgeThreshold ${ 230 | this.cashDeltaHedgeThreshold 231 | }, trigger position change: ${venue} ${asset}-${marketIndex}: ${size} @${ 232 | theo.theo 233 | }, forceRun: ${forceRun}` 234 | ); 235 | } else 236 | log.debug( 237 | `No ${asset} hedge orders issued due to uninitialized: theo ${toFixed( 238 | theo?.theo, 239 | 2 240 | )}, zetaAssetBaseSize ${zetaAssetBaseSize}, hedgeAssetBaseSize ${hedgeAssetBaseSize}, trigger position change: ${venue} ${asset}-${marketIndex}: ${size} @${ 241 | theo?.theo 242 | }, forceRun: ${forceRun}` 243 | ); 244 | 245 | if (venue == "zeta" && theo && size != prevSize) 246 | zetaPositionNotifications = [ 247 | { 248 | venue, 249 | asset, 250 | marketIndex, 251 | instrumentDescriptionShort: marketIndexShortDescription(marketIndex), 252 | instrumentDescription: this.getInstrumentDescription( 253 | asset, 254 | marketIndex 255 | ), 256 | baseSize: size, 257 | cashSize: size * theo.theo, 258 | topLevels: this.toTopLevels(), 259 | markPrice: theo.theo, 260 | }, 261 | ]; 262 | else if (venue == "hedge" && theo && size != prevSize) 263 | hedgePositionNotifications = [ 264 | { 265 | venue, 266 | asset, 267 | marketIndex, 268 | instrumentDescriptionShort: marketIndexShortDescription(marketIndex), 269 | instrumentDescription: this.getInstrumentDescription( 270 | asset, 271 | marketIndex 272 | ), 273 | baseSize: size, 274 | cashSize: size * theo.theo, 275 | topLevels: this.toTopLevels(), 276 | markPrice: theo.theo, 277 | }, 278 | ]; 279 | 280 | return { 281 | hedgeOrders, 282 | zetaPositionNotifications, 283 | hedgePositionNotifications, 284 | }; 285 | } 286 | 287 | setFunding(asset: constants.Asset, funding: number) { 288 | // Note only recording for now 289 | this.funding.set(asset, funding); 290 | } 291 | 292 | getTheo(asset: constants.Asset): Theo { 293 | return this.theos.get(asset); 294 | } 295 | 296 | getPosition( 297 | venue: Venue, 298 | asset?: constants.Asset, 299 | index?: MarketIndex 300 | ): PositionNotificationAgg { 301 | const positions = this.positionAgg.get({ 302 | venue, 303 | asset, 304 | marketIndex: index, 305 | }); 306 | if (positions.length == 0) return; 307 | 308 | let netBaseDelta = 0; 309 | let netCashDelta = 0; 310 | var fetchedAssets = new Set(); 311 | let theo: number; 312 | for (var [posKey, pos] of positions) { 313 | netBaseDelta += pos; 314 | netCashDelta += pos * this.theos.get(posKey.asset)?.theo ?? 0; 315 | fetchedAssets.add(posKey.asset); 316 | } 317 | return { 318 | positions: positions 319 | .filter(([posKey, _]) => this.theos.has(posKey.asset)) 320 | .map(([posKey, pos]) => { 321 | theo = this.theos.get(posKey.asset).theo; 322 | return { 323 | venue: posKey.venue, 324 | asset: posKey.asset, 325 | marketIndex: posKey.marketIndex, 326 | instrumentDescriptionShort: marketIndexShortDescription(index), 327 | instrumentDescription: this.getInstrumentDescription(asset, index), 328 | baseSize: pos, 329 | topLevels: this.toTopLevels(), 330 | cashSize: pos * theo, 331 | markPrice: theo, 332 | }; 333 | }), 334 | netCashDelta, 335 | // netBaseDelta only set if returned positions represent a single asset 336 | netBaseDelta: fetchedAssets.size <= 1 ? netBaseDelta : undefined, 337 | markPrice: fetchedAssets.size <= 1 ? theo : undefined, 338 | }; 339 | } 340 | 341 | // calculate quotes based on current exposure and quoteCashDelta/maxCashDelta params, price spread 342 | private calcQuotes( 343 | asset: constants.Asset, 344 | instruments: Instrument[], 345 | theo: Theo 346 | ): QuotingEffects { 347 | // get zeta position totals 348 | if (theo == undefined) { 349 | log.debug(`No theo for ${asset} yet`); 350 | return { quotes: [], breaches: [] }; 351 | } 352 | 353 | const quotes: Quote[] = []; 354 | for (var instrument of instruments) { 355 | let zetaBase = 356 | this.positionAgg.sum({ 357 | venue: "zeta", 358 | asset, 359 | marketIndex: instrument.marketIndex, 360 | }) ?? 0; 361 | 362 | let spread = calculateSpread( 363 | theo.theo, 364 | this.assetParams.get(asset).widthBps, 365 | zetaBase, 366 | this.cashDeltaHedgeThreshold, 367 | this.assetParams.get(asset).leanBps 368 | ); 369 | 370 | let level = 0; 371 | for (var { priceIncr, quoteCashDelta } of instrument.levels) { 372 | const params = this.assetParams.get(asset); 373 | let quoteSize = roundLotSize( 374 | quoteCashDelta / theo.theo, 375 | params.quoteLotSize 376 | ); 377 | quotes.push({ 378 | asset, 379 | side: "bid", 380 | marketIndex: instrument.marketIndex, 381 | level, 382 | price: spread.bid * (1 - priceIncr), 383 | size: quoteSize, 384 | clientOrderId: this.createClientId(), 385 | }); 386 | quotes.push({ 387 | asset, 388 | side: "ask", 389 | marketIndex: instrument.marketIndex, 390 | level, 391 | price: spread.ask * (1 + priceIncr), 392 | size: quoteSize, 393 | clientOrderId: this.createClientId(), 394 | }); 395 | level++; 396 | } 397 | } 398 | 399 | const breaches = this.getQuoteBreaches(asset); 400 | const rejectedQuoteTypes = new Set( 401 | breaches.flatMap((x) => x.rejectedQuoteTypes) 402 | ); 403 | if (rejectedQuoteTypes.size != 0) { 404 | let [rj] = rejectedQuoteTypes; 405 | log.info(`Reject ${rj}`); 406 | } 407 | const rejectBids = rejectedQuoteTypes.has("bid"); 408 | const rejectAsks = rejectedQuoteTypes.has("ask"); 409 | 410 | quotes.forEach((q) => { 411 | if ((rejectBids && q.side == "bid") || (rejectAsks && q.side == "ask")) 412 | q.size = 0; 413 | }); 414 | 415 | return { quotes, breaches }; 416 | } 417 | 418 | getQuoteBreaches(asset: constants.Asset): QuoteBreach[] { 419 | let theo = this.theos.get(asset)?.theo; 420 | if (theo == undefined) return []; 421 | 422 | const params = this.assetParams.get(asset); 423 | const indices = params.instruments.map((i) => i.marketIndex); 424 | const maxZetaCash = params.maxZetaCashExposure; 425 | const breachCandidates = indices.map((i) => { 426 | const base = 427 | this.positionAgg.sum({ venue: "zeta", asset, marketIndex: i }) ?? 0; 428 | const cash = base * theo; 429 | const rejectedQuoteTypes = 430 | Math.abs(cash) < maxZetaCash ? "neither" : cash > 0 ? "bid" : "ask"; 431 | return { 432 | type: "zeta", 433 | rejectedQuoteTypes, 434 | asset: asset, 435 | marketIndex: i, 436 | cash, 437 | limit: maxZetaCash, 438 | }; 439 | }); 440 | 441 | const netCash = (this.positionAgg.sum({ asset }) ?? 0) * theo; 442 | const maxNetCash = params.maxNetCashDelta; 443 | const rejectedQuoteTypes = 444 | Math.abs(netCash) < maxNetCash ? "neither" : netCash > 0 ? "bid" : "ask"; 445 | breachCandidates.push({ 446 | type: "net", 447 | rejectedQuoteTypes, 448 | asset: asset, 449 | marketIndex: undefined, 450 | cash: netCash, 451 | limit: maxNetCash, 452 | }); 453 | 454 | const breaches = breachCandidates 455 | .filter(({ rejectedQuoteTypes }) => rejectedQuoteTypes != "neither") 456 | .map((x) => x as QuoteBreach); 457 | return breaches; 458 | } 459 | 460 | private toTopLevels(): { 461 | venue: Venue; 462 | asset: constants.Asset; 463 | base: number; 464 | cash: number; 465 | }[] { 466 | const allAssets = Array.from(this.assetParams.keys()); 467 | return [ 468 | ...["zeta", "hedge"].flatMap((venue: Venue) => 469 | allAssets.map((asset) => { 470 | return { venue, asset }; 471 | }) 472 | ), 473 | ...allAssets.map((asset) => { 474 | return { 475 | venue: undefined, 476 | asset, 477 | }; 478 | }), 479 | ].map(({ venue, asset }) => { 480 | const base = this.positionAgg.sum({ venue, asset }) ?? 0; 481 | const mark = this.theos.get(asset)?.theo ?? 0; 482 | const cash = base * mark; 483 | return { venue, asset, base, cash }; 484 | }); 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /src/maker.ts: -------------------------------------------------------------------------------- 1 | // Orchestrates initialization (with fallback), listening to all the feeds, and processing of effects 2 | 3 | import { 4 | Wallet, 5 | CrossClient, 6 | Exchange, 7 | utils, 8 | assets, 9 | programTypes, 10 | events, 11 | Decimal, 12 | types, 13 | constants, 14 | } from "@zetamarkets/sdk"; 15 | 16 | import { Connection } from "@solana/web3.js"; 17 | import { Config } from "./configuration"; 18 | import { Effects, QuotingEffects, State } from "./state"; 19 | import { 20 | appendToCSV, 21 | idCreator, 22 | initializeClientState, 23 | instrumentDescription, 24 | marketIndexToName, 25 | schedule, 26 | convertToReadableOrders, 27 | } from "./utils"; 28 | import { BlockhashFetcher } from "./blockhash"; 29 | import { Exchange as ExchangePro, OrderBook, binanceusdm, pro } from "ccxt"; 30 | import { 31 | assetToMarket, 32 | assetToBinanceMarket, 33 | ITrader, 34 | MarketIndex, 35 | PositionNotificationAgg, 36 | QuoteBreach, 37 | RiskStats, 38 | Theo, 39 | Venue, 40 | ZetaRiskStats, 41 | } from "./types"; 42 | import { PermissionedTrader } from "./trader"; 43 | import { log } from "./log"; 44 | import { LockedRunner } from "./lock"; 45 | 46 | export class Maker { 47 | private config: Config; 48 | private blockhashFetcher: BlockhashFetcher; 49 | private state: State; 50 | private trader: ITrader; 51 | private zetaClient: CrossClient; 52 | private hedgeExchange: ExchangePro; 53 | private zetaRiskStats: ZetaRiskStats; // Q: should be part of state.rs? 54 | private hedgeRiskStats: RiskStats; // not assigned to an asset. Q: should be part of state.rs? 55 | private assets: constants.Asset[]; 56 | private lockedRunner: LockedRunner; 57 | private quotingBreaches: Map = new Map(); 58 | private binanceExchange: binanceusdm; 59 | 60 | constructor(config: Config) { 61 | this.config = config; 62 | this.blockhashFetcher = new BlockhashFetcher(config.endpoint); 63 | this.blockhashFetcher.subscribe(); 64 | this.assets = Array.from(config.assets.keys()); 65 | this.lockedRunner = new LockedRunner(config.lockingIntervalMs, [ 66 | ...this.assets.flatMap((asset) => [ 67 | `zeta-quotes-${asset}`, 68 | `hedge-orders-${asset}`, 69 | ]), 70 | ]); 71 | this.state = new State( 72 | config.cashDeltaHedgeThreshold, 73 | config.assets, 74 | instrumentDescription, 75 | idCreator() 76 | ); 77 | 78 | this.trader = new PermissionedTrader(config.tifExpiryOffsetMs / 1000); 79 | 80 | this.hedgeExchange = new pro[config.hedgeExchange]( 81 | config.credentials[config.hedgeExchange] 82 | ); 83 | if (config.useHedgeTestnet) 84 | this.hedgeExchange.urls["api"] = this.hedgeExchange.urls["test"]; 85 | 86 | this.binanceExchange = new binanceusdm(); 87 | } 88 | 89 | async initialize() { 90 | // Create a solana web3 connection. 91 | const connection = new Connection(this.config.endpoint, "processed"); 92 | const wallet = new Wallet(this.config.makerWallet); 93 | const assets = Array.from(this.config.assets.keys()); 94 | 95 | await Exchange.load( 96 | { 97 | network: this.config.network, 98 | connection, 99 | opts: utils.defaultCommitment(), 100 | throttleMs: 0, 101 | loadFromStore: true, 102 | }, 103 | undefined, 104 | async ( 105 | asset: constants.Asset, 106 | eventType: events.EventType, 107 | data: any 108 | ) => { 109 | if (this.zetaClient) 110 | return await this.handleZetaEvent(asset, eventType, data); 111 | } 112 | ); 113 | 114 | Exchange.toggleAutoPriorityFee(); 115 | 116 | this.zetaClient = await CrossClient.load( 117 | connection, 118 | wallet, 119 | undefined, 120 | async ( 121 | asset: constants.Asset, 122 | eventType: events.EventType, 123 | data: any 124 | ) => { 125 | if (this.zetaClient) 126 | return await this.handleZetaEvent(asset, eventType, data); 127 | } 128 | ); 129 | 130 | await initializeClientState(this.zetaClient); 131 | 132 | await this.trader.initialize( 133 | assets, 134 | this.blockhashFetcher, 135 | this.zetaClient, 136 | this.hedgeExchange 137 | ); 138 | 139 | try { 140 | // monitor hedge orderbook for price updates, via WS 141 | assets.forEach(async (asset) => { 142 | while (true) { 143 | const market = assetToMarket(asset); 144 | 145 | let orderbook: OrderBook; 146 | try { 147 | const market = assetToBinanceMarket(asset); 148 | orderbook = await this.binanceExchange.fetchOrderBook(market); 149 | } catch (e) { 150 | log.warn( 151 | `Fetching binance exchange failed, using bybit orderbook instead: ${e}` 152 | ); 153 | const market = assetToMarket(asset); 154 | orderbook = await this.hedgeExchange.watchOrderBook(market); 155 | } 156 | 157 | if (orderbook.bids.length > 0 && orderbook.asks.length > 0) { 158 | const nowTs = Date.now(); 159 | const ticker = { 160 | assetName: market, 161 | asset: asset, 162 | topLevel: { 163 | bid: { 164 | price: orderbook.bids[0][0], 165 | size: orderbook.bids[0][1], 166 | }, 167 | ask: { 168 | price: orderbook.asks[0][0], 169 | size: orderbook.asks[0][1], 170 | }, 171 | }, 172 | timestamp: nowTs, 173 | }; 174 | log.debug( 175 | `Setting ${asset} mark prices: BID ${ticker.topLevel.bid.size} @ ${ticker.topLevel.bid.price}, ASK ${ticker.topLevel.ask.size} @ ${ticker.topLevel.ask.price}` 176 | ); 177 | this.state.setMarkPriceUpdate(ticker, nowTs); 178 | 179 | // Note: not awaiting the following as that impacts the price setting cycle, causing stale prices 180 | this.lockedRunner.runExclusive( 181 | `zeta-quotes-${asset}`, 182 | `reject`, // won't re-quote if did so recently 183 | async () => { 184 | const effects = this.state.calcQuoting(asset); 185 | await this.handleQuotingEffects(effects, asset); 186 | log.debug(`Finished mark price update for ${asset}`); 187 | } 188 | ); 189 | } else 190 | log.debug( 191 | `Received empty ${asset} orderbook, skipping mark price update` 192 | ); 193 | } 194 | }); 195 | 196 | // monitor hedge trades, via WS 197 | // follow up with position re-fetch 198 | assets.forEach(async (asset) => { 199 | while (true) { 200 | const market = assetToMarket(asset); 201 | const myTrades = await this.hedgeExchange.watchMyTrades(market); 202 | await Promise.all( 203 | myTrades.map(async (trade) => 204 | console.log("Hedge exchange trade:", trade) 205 | ) 206 | ); 207 | if (myTrades.length > 0) 208 | await this.lockedRunner.runExclusive( 209 | `hedge-orders-${asset}`, 210 | `reject`, // won't publish hedge orders if done so recently 211 | async () => await this.refreshHedgePositions(asset) 212 | ); 213 | else 214 | log.debug( 215 | `Received no hedge trades for ${asset}, skipping position update` 216 | ); 217 | } 218 | }); 219 | 220 | await this.fetchZetaRiskStats(); 221 | await this.fetchHedgeRiskStats(); 222 | let totalBalance = 0; 223 | 224 | totalBalance += this.zetaRiskStats.balance + this.zetaRiskStats.pnlTotal; 225 | 226 | appendToCSV("balances/zeta-balance.csv", totalBalance.toFixed(2)); 227 | appendToCSV( 228 | "balances/hedge-balance.csv", 229 | (this.hedgeRiskStats.balance + this.hedgeRiskStats.pnl).toFixed(2) 230 | ); 231 | totalBalance += this.hedgeRiskStats.balance + this.hedgeRiskStats.pnl; 232 | appendToCSV("balances/total-balance.csv", totalBalance.toFixed(2)); 233 | 234 | setInterval(() => { 235 | let totalBalance = 0; 236 | 237 | totalBalance += 238 | this.zetaRiskStats.balance + this.zetaRiskStats.pnlTotal; 239 | appendToCSV("balances/zeta-balance.csv", totalBalance.toFixed(2)); 240 | appendToCSV( 241 | "balances/hedge-balance.csv", 242 | (this.hedgeRiskStats.balance + this.hedgeRiskStats.pnl).toFixed(2) 243 | ); 244 | totalBalance += this.hedgeRiskStats.balance + this.hedgeRiskStats.pnl; 245 | appendToCSV("balances/total-balance.csv", totalBalance.toFixed(2)); 246 | }, 10_800_000); 247 | 248 | // periodic refresh of all quotes, to eg. account for missing (filled) quotes 249 | schedule(async () => { 250 | await Promise.all( 251 | this.assets.map( 252 | async (asset) => 253 | await this.lockedRunner.runExclusive( 254 | `zeta-quotes-${asset}`, 255 | `wait`, // ensures snapshot update always happens 256 | async () => await this.refreshZetaQuotes(asset) 257 | ) 258 | ) 259 | ); 260 | }, this.config.quoteIntervalMs); 261 | 262 | schedule(async () => { 263 | await Promise.all( 264 | this.assets.map(async (asset) => { 265 | await this.lockedRunner.runExclusive( 266 | `hedge-orders-${asset}`, 267 | `wait`, // ensure snapshot update always happens 268 | async () => await this.refreshZetaPositions(asset) 269 | ); 270 | }) 271 | ); 272 | }, this.config.positionRefreshIntervalMs); 273 | 274 | schedule(async () => { 275 | await Promise.all( 276 | this.assets.map(async (asset) => { 277 | await this.lockedRunner.runExclusive( 278 | `hedge-orders-${asset}`, 279 | `wait`, // ensure snapshot update always happens 280 | async () => await this.refreshHedgePositions(asset) 281 | ); 282 | }) 283 | ); 284 | }, this.config.positionRefreshIntervalMs); 285 | 286 | // periodic fetch of risk stats 287 | schedule( 288 | this.fetchZetaRiskStats.bind(this), 289 | this.config.riskStatsFetchIntervalMs 290 | ); 291 | schedule( 292 | this.fetchHedgeRiskStats.bind(this), 293 | this.config.riskStatsFetchIntervalMs 294 | ); 295 | 296 | log.info(`Maker (${this.config.network}) initialized!`); 297 | } catch (e) { 298 | console.error(`Script error: ${e}`); 299 | this.trader.shutdown(); 300 | } 301 | } 302 | 303 | getZetaOrders(): any { 304 | return [convertToReadableOrders(this.zetaClient.orders)]; 305 | } 306 | 307 | getPosition( 308 | venue: Venue, 309 | asset: constants.Asset, 310 | index?: number 311 | ): PositionNotificationAgg { 312 | return this.state.getPosition(venue, asset, index); 313 | } 314 | 315 | getTheo(asset: constants.Asset): Theo { 316 | return this.state.getTheo(asset); 317 | } 318 | 319 | getRiskStats(venue: Venue): RiskStats | ZetaRiskStats { 320 | if (venue == "zeta") return this.zetaRiskStats; 321 | else return this.hedgeRiskStats; 322 | } 323 | 324 | getQuoteBreaches(): QuoteBreach[] { 325 | return Array.from(this.quotingBreaches.values()).flatMap((x) => x); 326 | } 327 | 328 | async shutdown() { 329 | await this.trader.shutdown(); 330 | } 331 | 332 | // recalculate zeta quotes as per currently set prices 333 | private async refreshZetaQuotes(asset: constants.Asset): Promise { 334 | await this.zetaClient.updateOrders(); 335 | const quotes = this.state.getCurrentQuotes(asset)?.quotes ?? []; 336 | 337 | let orders = this.zetaClient.getOrders(asset); 338 | const existingClientOrderIds = orders.map((x) => Number(x.clientOrderId)); 339 | const missingQuotes = quotes.filter( 340 | (x) => !existingClientOrderIds.includes(Number(x.clientOrderId)) 341 | ); 342 | if (missingQuotes.length > 0) { 343 | const effects = this.state.calcNewQuotes(asset); 344 | log.info( 345 | `Generating new quotes due to missing clientOrderIds ${missingQuotes 346 | .map((x) => `${x.clientOrderId}`) 347 | .join(", ")}:${effects.quotes 348 | .map((x) => `\n- ${JSON.stringify(x)}`) 349 | .join("")}` 350 | ); 351 | await this.handleQuotingEffects(effects, asset); 352 | } else 353 | log.debug( 354 | `Desired zeta quotes matching existing orders, no need to re-quote` 355 | ); 356 | } 357 | 358 | private async handleZetaEvent( 359 | _asset: constants.Asset, 360 | eventType: events.EventType, 361 | data: any 362 | ) { 363 | switch (eventType) { 364 | case events.EventType.TRADEV3: { 365 | const event = data as programTypes.TradeEventV3; 366 | let price = utils.getTradeEventPrice(event); 367 | let size = utils.convertNativeLotSizeToDecimal(event.size.toNumber()); 368 | let asset = assets.fromProgramAsset(event.asset); 369 | let side = event.isBid ? "[BID]" : "[ASK]"; 370 | let makerOrTaker = event.isTaker ? "[TAKER]" : "[MAKER]"; 371 | let name = marketIndexToName(asset, event.index); 372 | log.info( 373 | `[TRADE] [${asset}-${name}] ${side} ${makerOrTaker} [PRICE] ${price} [SIZE] ${size}` 374 | ); 375 | await this.lockedRunner.runExclusive( 376 | `hedge-orders-${asset}`, 377 | `reject`, 378 | async () => await this.refreshZetaPositions(asset) 379 | ); 380 | break; 381 | } 382 | case events.EventType.ORDERCOMPLETE: { 383 | const asset = Object.keys( 384 | data.asset 385 | )[0].toUpperCase() as constants.Asset; 386 | const orderCompleteType = Object.keys(data.orderCompleteType)[0]; 387 | const knownClientOrderIds = 388 | this.state 389 | .getCurrentQuotes(asset) 390 | ?.quotes.map((x) => x.clientOrderId) ?? []; 391 | const cancelledKnownQuote = knownClientOrderIds.includes( 392 | data.clientOrderId.toNumber() 393 | ); 394 | if (cancelledKnownQuote) { 395 | log.info( 396 | `Received ORDERCOMPLETE ${orderCompleteType} event on ${asset}-${ 397 | data.marketIndex 398 | }, known clientOrderId ${data.clientOrderId.toNumber()}, refreshing Zeta quotes` 399 | ); 400 | await this.lockedRunner.runExclusive( 401 | `zeta-quotes-${asset}`, 402 | `reject`, // won't re-quote if did so recently 403 | async () => await this.refreshZetaQuotes(asset) 404 | ); 405 | } else { 406 | // noop, presume it's been dealt with 407 | log.debug( 408 | `Received ORDERCOMPLETE ${orderCompleteType} event on ${asset}-${ 409 | data.marketIndex 410 | }, unknown clientOrderId ${data.clientOrderId.toNumber()}, skipping` 411 | ); 412 | } 413 | break; 414 | } 415 | case events.EventType.USER: { 416 | await Promise.all( 417 | this.assets.map(async (asset) => 418 | this.lockedRunner.runExclusive( 419 | `hedge-orders-${asset}`, 420 | `reject`, 421 | async () => await this.refreshZetaPositions(asset) 422 | ) 423 | ) 424 | ); 425 | break; 426 | } 427 | case events.EventType.PRICING: { 428 | Exchange.assets.map((a) => { 429 | let funding = Decimal.fromAnchorDecimal( 430 | Exchange.pricing.latestFundingRates[assets.assetToIndex(a)] 431 | ); 432 | let fundingAnnual = funding.toNumber() * 365 * 100; 433 | this.state.setFunding(a, fundingAnnual); 434 | }); 435 | break; 436 | } 437 | } 438 | } 439 | 440 | private async refreshHedgePositions(asset: constants.Asset) { 441 | // first sync up hedge exchange positions. Note: not handling effects yet 442 | try { 443 | const market = assetToMarket(asset); 444 | const res = await this.hedgeExchange.fetchPosition(market); 445 | const size = (res?.side == "long" ? 1 : -1) * (res?.contracts ?? 0); 446 | const effects = this.state.setPositionUpdate( 447 | "hedge", 448 | asset, 449 | MarketIndex.PERP, 450 | size, 451 | true 452 | ); 453 | 454 | await this.handleEffects(effects); 455 | } catch (e) { 456 | log.error( 457 | `Failed to refresh hedge positions for ${asset}, continuing...`, 458 | e 459 | ); 460 | } 461 | } 462 | 463 | private async refreshZetaPositions(asset: constants.Asset) { 464 | await Promise.all([this.zetaClient.updateState()]); 465 | 466 | let zetaMarginPositions = this.zetaClient.getPositions(asset); 467 | 468 | let totalPositions: Map = new Map(); 469 | 470 | for (var pos of zetaMarginPositions) { 471 | if (totalPositions.has(pos.marketIndex)) { 472 | let oldPosition = totalPositions.get(pos.marketIndex); 473 | oldPosition.costOfTrades += pos.costOfTrades; 474 | oldPosition.size += pos.size; 475 | totalPositions.set(pos.marketIndex, oldPosition); 476 | } else { 477 | totalPositions.set(pos.marketIndex, pos); 478 | } 479 | } 480 | 481 | totalPositions.forEach(async (v, k) => { 482 | try { 483 | const effects = this.state.setPositionUpdate( 484 | "zeta", 485 | asset, 486 | v.marketIndex, 487 | v.size 488 | ); 489 | await this.handleEffects(effects); 490 | } catch (e) { 491 | log.error( 492 | `Failed to refresh zeta positions for ${asset}-${pos.marketIndex} due to ${e}` 493 | ); 494 | } 495 | }); 496 | } 497 | 498 | private async fetchZetaRiskStats() { 499 | await Promise.all([this.zetaClient.updateState()]); 500 | log.debug(`Fetching zeta risk stats`); 501 | 502 | let marginState = this.zetaClient.getAccountState(); 503 | 504 | let perAssetMap = new Map(); 505 | for (let a of this.assets) { 506 | let ms = marginState.assetState.get(a); 507 | 508 | perAssetMap.set(a, { 509 | margin: ms.maintenanceMargin, 510 | pnl: ms.unrealizedPnl, 511 | }); 512 | } 513 | 514 | this.zetaRiskStats = { 515 | balance: marginState.balance, 516 | marginTotal: marginState.maintenanceMarginTotal, 517 | availableBalanceTotal: marginState.availableBalanceWithdrawable, 518 | pnlTotal: marginState.unrealizedPnlTotal, 519 | perAsset: perAssetMap, 520 | }; 521 | } 522 | 523 | private async fetchHedgeRiskStats(): Promise { 524 | try { 525 | log.debug(`Fetching hedge risk stats`); 526 | const balance = await this.hedgeExchange.fetchBalance({ 527 | coin: "USDT", 528 | }); 529 | 530 | const balanceRawInfo = balance.info.result.list.find( 531 | (x) => x.coin == "USDT" 532 | ); 533 | 534 | this.hedgeRiskStats = { 535 | balance: balance.USDT.total as number, // or balanceRawInfo.walletBalance 536 | margin: balance.USDT.used as number, // or balanceRawInfo.positionMargin 537 | availableBalance: balance.USDT.free as number, // or balanceRawInfo.availableBalance 538 | pnl: +balanceRawInfo.unrealisedPnl, 539 | }; 540 | } catch (e) { 541 | log.error( 542 | `Failed to update Hedge (${this.config.hedgeExchange}) risk stats, continuing...` 543 | ); 544 | } 545 | } 546 | 547 | private async handleEffects(effects: Effects) { 548 | const allPromises: Promise[] = []; 549 | if (effects.hedgeOrders.length > 0) { 550 | allPromises.push(this.trader.sendHedgeOrders(effects.hedgeOrders)); 551 | } 552 | await Promise.all(allPromises.map(async (p) => await p)); 553 | } 554 | 555 | private async handleQuotingEffects( 556 | effects: QuotingEffects, 557 | asset: constants.Asset 558 | ) { 559 | try { 560 | this.quotingBreaches.set(asset, effects.breaches); 561 | 562 | if (effects.quotes.length > 0) 563 | await this.trader.sendZetaQuotes(effects.quotes); 564 | } catch (e) { 565 | // swallow error, will be retried 566 | log.error( 567 | `Failed to send ${ 568 | effects.quotes.length 569 | } zeta quotes for ${asset}: ${effects.quotes 570 | .map((q) => `\n- ${JSON.stringify(q)}`) 571 | .join("")} 572 | due to ${e}` 573 | ); 574 | } 575 | } 576 | } 577 | --------------------------------------------------------------------------------