feat: 添加 Lighter 适配器及相关功能,支持 trailing stops 和新的交易逻辑

This commit is contained in:
discountry
2025-09-30 22:08:05 +08:00
parent e83bdfccf9
commit c8b0ab1c8e
101 changed files with 90621 additions and 3 deletions
+291
View File
@@ -0,0 +1,291 @@
import type {
LighterAccountDetails,
LighterKline,
LighterMarketStats,
LighterOrderBookMetadata,
} from "./types";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
interface ApiResponseBase {
code: number;
message?: string | null;
}
interface OrderBooksResponse extends ApiResponseBase {
order_books?: LighterOrderBookMetadata[];
}
interface ExchangeStatsResponse extends ApiResponseBase {
order_book_stats?: Array<{
market_id: number;
index_price?: string;
mark_price?: string;
last_trade_price: string;
open_interest?: string;
daily_base_token_volume?: number;
daily_quote_token_volume?: number;
daily_price_low?: number;
daily_price_high?: number;
daily_price_change?: number;
symbol: string;
funding_rate?: string;
funding_timestamp?: number;
current_funding_rate?: string;
}>;
}
interface NextNonceResponse extends ApiResponseBase {
nonce: number;
}
interface SendTxResponse extends ApiResponseBase {
tx_hash: string;
predicted_execution_time_ms?: number;
}
interface CandlesticksResponse extends ApiResponseBase {
resolution: string;
candlesticks: Array<{
start_timestamp?: number;
end_timestamp?: number;
timestamp?: number;
open: number | string;
high: number | string;
low: number | string;
close: number | string;
base_token_volume: number | string;
quote_token_volume: number | string;
trades?: number;
}>;
}
interface AccountResponse extends ApiResponseBase {
account?: LighterAccountDetails;
}
export interface LighterHttpClientOptions {
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
priceProtection?: boolean;
fetcher?: typeof fetch;
}
export class LighterHttpClient {
readonly baseUrl: string;
private readonly priceProtection: boolean;
private readonly fetcher: typeof fetch;
constructor(options: LighterHttpClientOptions = {}) {
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment: ${env}`);
}
this.baseUrl = host.replace(/\/$/, "");
this.priceProtection = options.priceProtection ?? true;
this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
if (!this.fetcher) {
throw new Error("Global fetch is not available; provide a custom fetch implementation");
}
}
async getOrderBooks(): Promise<LighterOrderBookMetadata[]> {
const response = await this.get<OrderBooksResponse>("/api/v1/orderBooks");
return response.order_books ?? [];
}
async getExchangeStats(): Promise<LighterMarketStats[]> {
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
const stats = response.order_book_stats ?? [];
return stats.map((entry) => ({
market_id: entry.market_id,
symbol: entry.symbol,
index_price: entry.index_price ?? entry.mark_price ?? entry.last_trade_price,
mark_price: entry.mark_price ?? entry.last_trade_price,
last_trade_price: entry.last_trade_price,
open_interest: entry.open_interest ?? "0",
daily_base_token_volume: entry.daily_base_token_volume,
daily_quote_token_volume: entry.daily_quote_token_volume,
daily_price_low: entry.daily_price_low,
daily_price_high: entry.daily_price_high,
daily_price_change: entry.daily_price_change,
current_funding_rate: entry.current_funding_rate,
funding_rate: entry.funding_rate,
funding_timestamp: entry.funding_timestamp,
}));
}
async getAccountDetails(
accountIndex: number,
authToken?: string,
options: { by?: "index" | "l1_address"; value?: string | number } = {}
): Promise<LighterAccountDetails | null> {
const query: Record<string, unknown> = {};
if (authToken) {
query.auth = authToken;
}
const by = options.by ?? "index";
query.by = by;
if (options.value !== undefined) {
query.value = options.value;
} else if (by === "index") {
query.value = accountIndex;
}
const response = await this.get<AccountResponse>("/api/v1/account", {
query,
headers: authToken ? { Authorization: authToken } : undefined,
tolerateNotFound: true,
});
return response.account ?? null;
}
async getCandlesticks(params: {
marketId: number;
resolution: string;
countBack: number;
startTimestamp: number;
endTimestamp: number;
setTimestampToEnd?: boolean;
}): Promise<LighterKline[]> {
const response = await this.get<CandlesticksResponse>("/api/v1/candlesticks", {
query: {
market_id: params.marketId,
resolution: params.resolution,
count_back: params.countBack,
start_timestamp: params.startTimestamp,
end_timestamp: params.endTimestamp,
set_timestamp_to_end: params.setTimestampToEnd ?? true,
},
});
return (response.candlesticks ?? []).map((entry) => ({
start_timestamp: entry.start_timestamp ?? entry.timestamp ?? 0,
end_timestamp:
entry.end_timestamp ??
(entry.start_timestamp ?? entry.timestamp ?? 0) + 1,
open: String(entry.open ?? 0),
high: String(entry.high ?? 0),
low: String(entry.low ?? 0),
close: String(entry.close ?? 0),
base_token_volume: String(entry.base_token_volume ?? 0),
quote_token_volume: String(entry.quote_token_volume ?? 0),
trades: entry.trades,
}));
}
async getNextNonce(accountIndex: number, apiKeyIndex: number): Promise<bigint> {
const response = await this.get<NextNonceResponse>("/api/v1/nextNonce", {
query: {
account_index: accountIndex,
api_key_index: apiKeyIndex,
},
});
if (typeof response.nonce !== "number") {
throw new Error("Lighter nextNonce response missing nonce");
}
return BigInt(response.nonce);
}
async sendTransaction(
txType: number,
txInfo: string,
options: { priceProtection?: boolean; authToken?: string } = {}
): Promise<SendTxResponse> {
const form = new FormData();
form.set("tx_type", String(txType));
form.set("tx_info", txInfo);
const priceProtection = options.priceProtection ?? this.priceProtection;
if (priceProtection != null) form.set("price_protection", String(priceProtection));
return this.postForm<SendTxResponse>("/api/v1/sendTx", form, options.authToken);
}
private async get<T extends ApiResponseBase>(
path: string,
options: {
query?: Record<string, unknown>;
headers?: Record<string, string | undefined>;
tolerateNotFound?: boolean;
} = {}
): Promise<T> {
const url = new URL(path, `${this.baseUrl}`);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
}
const requestUrl = url.toString();
const response = await this.fetcher(requestUrl, {
method: "GET",
headers: {
"Accept": "application/json",
...this.cleanHeaders(options.headers),
},
});
if (options.tolerateNotFound && response.status === 404) {
return { code: 404 } as T;
}
return this.parseResponse<T>(response, requestUrl);
}
private async post<T extends ApiResponseBase>(path: string, body: unknown, authToken?: string): Promise<T> {
const requestUrl = new URL(path, `${this.baseUrl}`).toString();
const response = await this.fetcher(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(authToken ? { Authorization: authToken } : {}),
},
body: JSON.stringify(body),
});
return this.parseResponse<T>(response, requestUrl);
}
private async postForm<T extends ApiResponseBase>(path: string, form: FormData, authToken?: string): Promise<T> {
const requestUrl = new URL(path, `${this.baseUrl}`).toString();
const response = await this.fetcher(requestUrl, {
method: "POST",
headers: {
Accept: "application/json",
...(authToken ? { Authorization: authToken } : {}),
},
body: form as any,
});
return this.parseResponse<T>(response, requestUrl);
}
private cleanHeaders(headers?: Record<string, string | undefined>): Record<string, string> | undefined {
if (!headers) return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value) result[key] = value;
}
return Object.keys(result).length ? result : undefined;
}
private async parseResponse<T extends ApiResponseBase>(response: Response, requestUrl: string): Promise<T> {
const text = await response.text();
if (!response.ok) {
const snippet = text ? truncateBody(text) : response.statusText;
throw new Error(`Lighter HTTP ${response.status} ${response.statusText} (${requestUrl}): ${snippet}`);
}
if (!text) {
throw new Error(`Empty Lighter response body (${requestUrl})`);
}
let parsed: T;
try {
parsed = JSON.parse(text) as T;
} catch (error) {
throw new Error(`Failed to parse Lighter response (${requestUrl}): ${String(error)}. Body: ${truncateBody(text)}`);
}
if (typeof parsed.code === "number" && parsed.code !== 200) {
throw new Error(parsed.message ?? `Lighter API returned code ${parsed.code} (${requestUrl})`);
}
return parsed;
}
}
function truncateBody(body: string, limit = 200): string {
return body.length > limit ? `${body.slice(0, limit)}` : body;
}