Add token expiry and Telegram notification features

- Introduced `STANDX_TOKEN_EXPIRY` configuration to manage token expiration, including handling logic for active, expired, and silent states.
- Implemented Telegram notifications for key events such as order filled, position opened/closed, stop loss triggered, and token expiration.
- Updated Maker Points engine to integrate token expiry checks and notification sending, enhancing user awareness of trading conditions.
- Enhanced documentation to include details on configuring token expiry and Telegram notifications for improved user guidance.
This commit is contained in:
discountry
2026-01-10 12:44:52 +08:00
parent cb1cef6f1b
commit 598f2a0eb6
8 changed files with 611 additions and 4 deletions
+39
View File
@@ -6,6 +6,45 @@
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
import { language, type Language } from "./i18n";
export interface StandxTokenConfig {
expiryTimestamp: number | null;
}
function parseTimestamp(value: string | undefined): number | null {
if (!value || !value.trim()) return null;
const trimmed = value.trim();
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber) && asNumber > 0) {
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
}
const asDate = Date.parse(trimmed);
if (Number.isFinite(asDate) && asDate > 0) {
return asDate;
}
return null;
}
export const standxTokenConfig: StandxTokenConfig = {
expiryTimestamp: parseTimestamp(process.env.STANDX_TOKEN_EXPIRY),
};
export function isStandxTokenExpired(): boolean {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) return false;
return Date.now() >= expiry;
}
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) {
return { expired: false, expiryTimestamp: null, remainingMs: null };
}
const now = Date.now();
const expired = now >= expiry;
const remainingMs = expired ? 0 : expiry - now;
return { expired, expiryTimestamp: expiry, remainingMs };
}
export interface TradingConfig {
symbol: string;
tradeAmount: number;
+12
View File
@@ -0,0 +1,12 @@
export type {
NotificationLevel,
TradeNotification,
NotificationSender,
NotificationConfig,
} from "./types";
export {
TelegramNotifier,
createTelegramNotifier,
type TelegramConfig,
} from "./telegram";
+113
View File
@@ -0,0 +1,113 @@
import type { NotificationSender, TradeNotification, NotificationConfig } from "./types";
export interface TelegramConfig extends NotificationConfig {
botToken: string;
chatId: string;
}
const LEVEL_EMOJI: Record<string, string> = {
info: "️",
warn: "⚠️",
error: "🚨",
success: "✅",
};
const TYPE_EMOJI: Record<string, string> = {
order_filled: "📝",
position_opened: "📈",
position_closed: "📉",
stop_loss: "🛑",
token_expired: "⏰",
custom: "📢",
};
function formatNotificationMessage(notification: TradeNotification, accountLabel?: string): string {
const levelEmoji = LEVEL_EMOJI[notification.level] ?? "";
const typeEmoji = TYPE_EMOJI[notification.type] ?? "";
const timestamp = notification.timestamp ?? Date.now();
const time = new Date(timestamp).toISOString().replace("T", " ").substring(0, 19);
const label = notification.accountLabel ?? accountLabel ?? notification.symbol;
const lines: string[] = [
`${typeEmoji}${levelEmoji} [${label}] ${notification.title}`,
``,
`${notification.message}`,
];
if (notification.details && Object.keys(notification.details).length > 0) {
lines.push(``);
for (const [key, value] of Object.entries(notification.details)) {
if (value != null) {
lines.push(`${key}: ${value}`);
}
}
}
lines.push(``);
lines.push(`🕐 ${time} UTC`);
lines.push(`📊 ${notification.symbol}`);
return lines.join("\n");
}
export class TelegramNotifier implements NotificationSender {
private readonly config: TelegramConfig;
private readonly baseUrl: string;
private sendQueue: Promise<void> = Promise.resolve();
constructor(config: Partial<TelegramConfig> = {}) {
this.config = {
enabled: Boolean(config.botToken && config.chatId),
botToken: config.botToken ?? "",
chatId: config.chatId ?? "",
accountLabel: config.accountLabel,
};
this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`;
}
isEnabled(): boolean {
return this.config.enabled;
}
async send(notification: TradeNotification): Promise<void> {
if (!this.isEnabled()) {
return;
}
this.sendQueue = this.sendQueue
.then(() => this.doSend(notification))
.catch(() => {});
}
private async doSend(notification: TradeNotification): Promise<void> {
const text = formatNotificationMessage(notification, this.config.accountLabel);
const url = `${this.baseUrl}/sendMessage`;
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: this.config.chatId,
text,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
console.error(`[Telegram] Failed to send notification: ${response.status} ${errorText}`);
}
} catch (error) {
console.error(`[Telegram] Failed to send notification: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
export function createTelegramNotifier(): TelegramNotifier {
return new TelegramNotifier({
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
accountLabel: process.env.TELEGRAM_ACCOUNT_LABEL,
});
}
+22
View File
@@ -0,0 +1,22 @@
export type NotificationLevel = "info" | "warn" | "error" | "success";
export interface TradeNotification {
type: "order_filled" | "position_opened" | "position_closed" | "stop_loss" | "token_expired" | "custom";
level: NotificationLevel;
symbol: string;
title: string;
message: string;
accountLabel?: string;
details?: Record<string, string | number | boolean | null>;
timestamp?: number;
}
export interface NotificationSender {
send(notification: TradeNotification): Promise<void>;
isEnabled(): boolean;
}
export interface NotificationConfig {
enabled: boolean;
accountLabel?: string;
}
+221 -4
View File
@@ -29,6 +29,17 @@ import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
import { buildBpsTargets } from "./maker-points-logic";
import { t } from "../i18n";
import {
checkStandxTokenExpiry,
formatTokenExpiryMessage,
isTokenExpiryConfigured,
type TokenExpiryState,
} from "../utils/standx-token-expiry";
import {
createTelegramNotifier,
type NotificationSender,
type TradeNotification,
} from "../notifications";
interface DesiredOrder {
side: "BUY" | "SELL";
@@ -90,6 +101,7 @@ export class MakerPointsEngine {
private readonly sessionVolume = new SessionVolumeTracker();
private readonly rateLimit: RateLimitController;
private readonly binanceDepth: BinanceDepthTracker;
private readonly notifier: NotificationSender;
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
@@ -128,11 +140,21 @@ export class MakerPointsEngine {
private insufficientBalanceNotified = false;
private lastInsufficientMessage: string | null = null;
private tokenExpiryState: TokenExpiryState = "active";
private tokenExpiryLogged = false;
private tokenExpiryCancelDone = false;
private tokenExpiredCloseOnlyMode = false;
private tokenExpiryNotified = false;
private lastPositionAmt = 0;
private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT";
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.notifier = createTelegramNotifier();
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.config.qtyStep);
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
@@ -201,6 +223,7 @@ export class MakerPointsEngine {
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
this.detectPositionChange(position);
this.feedStatus.account = true;
this.emitUpdate();
},
@@ -314,6 +337,14 @@ export class MakerPointsEngine {
return;
}
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
if (await this.handleTokenExpiry(position, absPosition)) {
this.emitUpdate();
return;
}
const depth = this.depthSnapshot!;
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) {
@@ -321,13 +352,12 @@ export class MakerPointsEngine {
return;
}
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
const closeThreshold = Number(this.config.closeThreshold);
const closeOnly =
Number.isFinite(closeThreshold) &&
this.tokenExpiredCloseOnlyMode ||
(Number.isFinite(closeThreshold) &&
closeThreshold > 0 &&
absPosition >= closeThreshold - EPS;
absPosition >= closeThreshold - EPS);
const prevCloseOnly = this.lastCloseOnly;
if (closeOnly !== prevCloseOnly) {
this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式");
@@ -615,6 +645,19 @@ export class MakerPointsEngine {
"stop",
`触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT`
);
this.notifier.send({
type: "stop_loss",
level: "error",
symbol: this.config.symbol,
title: "止损触发",
message: `未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT,强制平仓`,
details: {
side: position.positionAmt > 0 ? "LONG" : "SHORT",
size: absPosition,
unrealizedPnl: position.unrealizedProfit,
lossLimit: -lossLimit,
},
});
try {
await this.flushOrders();
await marketClose(
@@ -828,6 +871,180 @@ export class MakerPointsEngine {
}
return active;
}
private detectPositionChange(position: PositionSnapshot): void {
const currentAmt = position.positionAmt;
const currentSide: "LONG" | "SHORT" | "FLAT" =
currentAmt > EPS ? "LONG" : currentAmt < -EPS ? "SHORT" : "FLAT";
const prevAmt = this.lastPositionAmt;
const prevSide = this.lastPositionSide;
if (Math.abs(currentAmt - prevAmt) < EPS && currentSide === prevSide) {
return;
}
const absChange = Math.abs(currentAmt - prevAmt);
const reference = this.getReferencePrice() ?? 0;
if (prevSide === "FLAT" && currentSide !== "FLAT") {
this.notifier.send({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "开仓",
message: `${currentSide === "LONG" ? "做多" : "做空"} ${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
size: Math.abs(currentAmt),
price: reference > 0 ? reference : null,
},
});
} else if (currentSide === "FLAT" && prevSide !== "FLAT") {
const pnl = position.unrealizedProfit;
const closeType = this.tokenExpiredCloseOnlyMode ? "Token过期平仓" : "平仓";
this.notifier.send({
type: "position_closed",
level: "success",
symbol: this.config.symbol,
title: closeType,
message: `已平仓 ${Math.abs(prevAmt).toFixed(6)} (${prevSide === "LONG" ? "多" : "空"})`,
details: {
prevSide,
closedSize: Math.abs(prevAmt),
pnl: Number.isFinite(pnl) ? pnl : null,
},
});
} else if (currentSide === prevSide && absChange > EPS) {
const isIncrease = Math.abs(currentAmt) > Math.abs(prevAmt);
if (isIncrease) {
this.notifier.send({
type: "order_filled",
level: "info",
symbol: this.config.symbol,
title: "加仓",
message: `${currentSide === "LONG" ? "做多" : "做空"} +${absChange.toFixed(6)}${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
added: absChange,
totalSize: Math.abs(currentAmt),
},
});
} else {
this.notifier.send({
type: "order_filled",
level: "info",
symbol: this.config.symbol,
title: "减仓",
message: `${currentSide === "LONG" ? "多" : "空"} -${absChange.toFixed(6)}${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
reduced: absChange,
totalSize: Math.abs(currentAmt),
},
});
}
} else if (currentSide !== prevSide && currentSide !== "FLAT" && prevSide !== "FLAT") {
this.notifier.send({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "反向开仓",
message: `${prevSide === "LONG" ? "多→空" : "空→多"} ${Math.abs(currentAmt).toFixed(6)}`,
details: {
prevSide,
newSide: currentSide,
size: Math.abs(currentAmt),
},
});
}
this.lastPositionAmt = currentAmt;
this.lastPositionSide = currentSide;
}
private async handleTokenExpiry(position: PositionSnapshot, absPosition: number): Promise<boolean> {
if (!isTokenExpiryConfigured()) {
return false;
}
const expiryStatus = checkStandxTokenExpiry({
positionAmt: position.positionAmt,
openOrderCount: this.openOrders.length,
});
if (!expiryStatus.expired) {
if (this.tokenExpiryState !== "active") {
this.tokenExpiryState = "active";
this.tokenExpiryLogged = false;
this.tokenExpiryCancelDone = false;
this.tokenExpiredCloseOnlyMode = false;
this.tokenExpiryNotified = false;
}
return false;
}
const prevState = this.tokenExpiryState;
this.tokenExpiryState = expiryStatus.state;
if (!this.tokenExpiryLogged) {
const message = formatTokenExpiryMessage(expiryStatus);
if (message) {
this.tradeLog.push("warn", message);
}
this.tokenExpiryLogged = true;
}
if (!this.tokenExpiryNotified) {
this.notifier.send({
type: "token_expired",
level: "warn",
symbol: this.config.symbol,
title: "Token 已过期",
message: expiryStatus.hasPosition
? "Token 已过期,进入平仓模式,不再开新仓"
: "Token 已过期,策略进入静默模式",
details: {
hasPosition: expiryStatus.hasPosition,
hasOpenOrders: expiryStatus.hasOpenOrders,
state: expiryStatus.state,
},
});
this.tokenExpiryNotified = true;
}
if (!this.tokenExpiryCancelDone && this.openOrders.length > 0) {
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.tradeLog.push("order", "Token 过期,已撤销所有挂单");
this.openOrders = [];
this.tokenExpiryCancelDone = true;
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "Token 过期撤单时订单已不存在");
this.tokenExpiryCancelDone = true;
} else {
this.tradeLog.push("error", `Token 过期撤单失败: ${extractMessage(error)}`);
}
}
}
if (expiryStatus.state === "expired_with_position") {
if (!this.tokenExpiredCloseOnlyMode) {
this.tokenExpiredCloseOnlyMode = true;
this.tradeLog.push("info", "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单");
}
return false;
}
if (expiryStatus.state === "silent") {
if (prevState !== "silent") {
this.tradeLog.push("info", "进入静默数据接收模式,不再进行任何交易操作");
}
return true;
}
return true;
}
}
function resolveBinanceSymbol(symbol: string): string {
+92
View File
@@ -0,0 +1,92 @@
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
export interface TokenExpiryStatus {
state: TokenExpiryState;
expired: boolean;
expiryTimestamp: number | null;
remainingMs: number | null;
hasPosition: boolean;
hasOpenOrders: boolean;
}
export interface TokenExpiryCheckParams {
positionAmt: number;
openOrderCount: number;
}
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
const info = getStandxTokenExpiryInfo();
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
const hasOpenOrders = params.openOrderCount > 0;
if (!info.expired) {
return {
state: "active",
expired: false,
expiryTimestamp: info.expiryTimestamp,
remainingMs: info.remainingMs,
hasPosition,
hasOpenOrders,
};
}
if (hasPosition) {
return {
state: "expired_with_position",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: true,
hasOpenOrders,
};
}
if (!hasOpenOrders) {
return {
state: "silent",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: false,
hasOpenOrders: false,
};
}
return {
state: "expired",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition,
hasOpenOrders,
};
}
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
if (!status.expired) {
if (status.remainingMs != null && status.remainingMs < 3600_000) {
const mins = Math.ceil(status.remainingMs / 60_000);
return `StandX Token 将在 ${mins} 分钟后过期`;
}
return null;
}
switch (status.state) {
case "expired":
return "StandX Token 已过期,正在取消所有挂单";
case "expired_with_position":
return "StandX Token 已过期,仅保留平仓/止损逻辑";
case "silent":
return "StandX Token 已过期,进入静默数据接收模式";
default:
return null;
}
}
export function isTokenExpiryConfigured(): boolean {
return standxTokenConfig.expiryTimestamp != null;
}
export { isStandxTokenExpired, getStandxTokenExpiryInfo };