update
This commit is contained in:
parent
e45c62215e
commit
d8f8b04943
15 changed files with 458 additions and 162 deletions
|
|
@ -6,7 +6,7 @@ import positionsApi from './api/positions';
|
|||
import candlesApi from './api/candles';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import YAML from 'yamljs';
|
||||
import './schedule/candleAnalysisSchedule';
|
||||
import './schedule';
|
||||
|
||||
|
||||
const app: Application = express();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
export interface Analysis {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
emaDirection: string;
|
||||
macdDirection: string;
|
||||
isMacdCrossUp: boolean;
|
||||
isMacdCrossDown: boolean;
|
||||
isMacdUpper: boolean;
|
||||
isMacdLower: boolean;
|
||||
isEmaCrossUp: boolean;
|
||||
isEmaCrossDown: boolean;
|
||||
isPinBar: boolean;
|
||||
isHighVolatility: boolean;
|
||||
isBuy: boolean;
|
||||
isSell: boolean;
|
||||
isTouch200: boolean;
|
||||
isReverse200: boolean;
|
||||
lowHight: number;
|
||||
numberTouch200: number;
|
||||
numberMacdCrossUp: number;
|
||||
numberMacdCrossDown: number;
|
||||
}
|
||||
9
src/dao/order.ts
Normal file
9
src/dao/order.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface Order {
|
||||
symbol: string;
|
||||
side: "buy" | "sell";
|
||||
entry: number;
|
||||
volume: number;
|
||||
leverage?: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
}
|
||||
9
src/dao/position.ts
Normal file
9
src/dao/position.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface Position {
|
||||
symbol: string;
|
||||
side: string;
|
||||
entry: number;
|
||||
leverage: number;
|
||||
volume: number;
|
||||
profit: number;
|
||||
profitPercentage: number;
|
||||
}
|
||||
10
src/dao/subwave.ts
Normal file
10
src/dao/subwave.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface Subwave {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
subwave: string;
|
||||
subwaveDirection: string;
|
||||
subwaveType: string;
|
||||
subwaveLength: number;
|
||||
subwaveStart: number;
|
||||
subwaveEnd: number;
|
||||
}
|
||||
9
src/dao/wave.ts
Normal file
9
src/dao/wave.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface Wave {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
trend: 'Bullish' | 'Bearish';
|
||||
numberTouchEma: number;
|
||||
numberMacdCrossUp: number;
|
||||
numberMacdCrossDown: number;
|
||||
lowOrHighPrice: number;
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { BybitService } from '../services/bybitService';
|
||||
import * as indicatorService from '../services/indicatorService';
|
||||
import { IndicatorService } from '../services/indicatorService';
|
||||
import { Candle } from '../dao/candles';
|
||||
import { KlineIntervalV3 } from 'bybit-api';
|
||||
|
||||
export const analyzeCandles = async (req: Request, res: Response) => {
|
||||
const { symbol, interval } = req.params;
|
||||
const { symbol, interval: intervalString } = req.params;
|
||||
const interval = intervalString as KlineIntervalV3;
|
||||
const bybitService = new BybitService(process.env.BYBIT_API_KEY!, process.env.BYBIT_API_SECRET!);
|
||||
const candles = await bybitService.getCandles({ symbol, interval: '5', category: 'linear', limit: 200 });
|
||||
const analysis = indicatorService.analyze(candles);
|
||||
const candles = await bybitService.getCandles({ symbol, interval, category: 'linear', limit: 200 });
|
||||
const indicatorService = new IndicatorService();
|
||||
const analysis = indicatorService.analyze(candles, {
|
||||
symbol,
|
||||
interval,
|
||||
trend: 'Bullish',
|
||||
numberTouchEma: 0,
|
||||
numberMacdCrossUp: 0,
|
||||
numberMacdCrossDown: 0,
|
||||
lowOrHighPrice: 0,
|
||||
});
|
||||
res.json(analysis);
|
||||
};
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
interface Candle {
|
||||
open: number;
|
||||
close: number;
|
||||
low: number;
|
||||
high: number;
|
||||
}
|
||||
import { Candle } from "../dao/candles";
|
||||
|
||||
type PatternType =
|
||||
| "Doji"
|
||||
|
|
@ -647,3 +642,28 @@ export function isHighVolatilityCandle(candles: Candle[]): boolean {
|
|||
// A candle is considered high volatility if either its range or its body size (or both) are significantly larger
|
||||
return isRangeHigh || isBodySizeHigh;
|
||||
}
|
||||
|
||||
function upperShadow(candle: Candle): number {
|
||||
return candle.high - Math.max(candle.open, candle.close);
|
||||
}
|
||||
|
||||
function lowerShadow(candle: Candle): number {
|
||||
return Math.min(candle.open, candle.close) - candle.low;
|
||||
}
|
||||
|
||||
export function isPinBar(candle: Candle): {isPinBar: boolean, isBullish: boolean, isBearish: boolean} {
|
||||
const bodySize = getBodySize(candle);
|
||||
const totalRange = getTotalRange(candle);
|
||||
const upperShadowSize = upperShadow(candle);
|
||||
const lowerShadowSize = lowerShadow(candle);
|
||||
|
||||
if (bodySize < totalRange * 0.3 && upperShadowSize > bodySize * 2) {
|
||||
return {isPinBar: true, isBullish: true, isBearish: false};
|
||||
}
|
||||
|
||||
if (bodySize < totalRange * 0.3 && lowerShadowSize > bodySize * 2) {
|
||||
return {isPinBar: true, isBullish: false, isBearish: true};
|
||||
}
|
||||
|
||||
return {isPinBar: false, isBullish: false, isBearish: false};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,94 @@
|
|||
import schedule from 'node-schedule';
|
||||
import * as indicatorService from '../services/indicatorService';
|
||||
import { IndicatorService } from '../services/indicatorService';
|
||||
import { BybitService } from '../services/bybitService';
|
||||
import { KlineIntervalV3 } from 'bybit-api';
|
||||
import { sendLarkMessage } from '../services/messageService';
|
||||
import { Candle } from '../dao/candles';
|
||||
import { Order } from '../dao/order';
|
||||
|
||||
// Hàm thực hiện phân tích nến
|
||||
async function analyzeCandlesJob(symbol: string, interval: KlineIntervalV3) {
|
||||
const bybitService = new BybitService(process.env.BYBIT_API_KEY!, process.env.BYBIT_API_SECRET!, false);
|
||||
// TODO: Lấy client thật nếu cần
|
||||
const candles = await bybitService.getCandles({ symbol, interval, category: 'linear', limit: 200 });
|
||||
const analysis = indicatorService.analyze(candles);
|
||||
await sendLarkMessage('oc_f9b2e8f0309ecab0c94e3e134b0ddd29', JSON.stringify(analysis));
|
||||
// Có thể gửi kết quả qua Lark, lưu DB, ...
|
||||
const indicatorService = new IndicatorService();
|
||||
const bybitService = new BybitService(process.env.BYBIT_API_KEY!, process.env.BYBIT_API_SECRET!, false);
|
||||
|
||||
function sendMessage(message: string) {
|
||||
sendLarkMessage('oc_f9b2e8f0309ecab0c94e3e134b0ddd29', message);
|
||||
}
|
||||
|
||||
// Lập lịch chạy vào 00:00 mỗi ngày
|
||||
const rule = new schedule.RecurrenceRule();
|
||||
rule.minute = [4, 9, 14, 19, 24, 29, 34, 39, 44, 49, 54, 59];
|
||||
rule.second = 59;
|
||||
// Hàm thực hiện phân tích nến
|
||||
export async function analyzeCandlesJob(symbol: string, interval: KlineIntervalV3, end?: number) {
|
||||
// TODO: Lấy client thật nếu cần
|
||||
const candles = await bybitService.getCandles({ symbol, interval, category: 'linear', limit: 500, end });
|
||||
const wave = await indicatorService.getWave(symbol, interval);
|
||||
console.log(wave);
|
||||
const analysis= indicatorService.analyze(candles, wave);
|
||||
indicatorService.handleEvent(analysis, candles, {
|
||||
onBuy: (order: Order, reason: string) => {
|
||||
sendMessage(`Buy ${symbol} ${interval}M ${reason}
|
||||
symbol: ${order.symbol}
|
||||
side: ${order.side}
|
||||
entry: ${order.entry}
|
||||
stopLoss: ${order.stopLoss}
|
||||
volume: ${order.volume}
|
||||
reason: ${reason}
|
||||
`);
|
||||
console.log(`Buy ${symbol} ${interval}M ${reason} ${candles[0].time}`);
|
||||
},
|
||||
onSell: (order: Order, reason: string) => {
|
||||
sendMessage(`Sell ${symbol} ${interval}M ${reason}
|
||||
symbol: ${order.symbol}
|
||||
side: ${order.side}
|
||||
entry: ${order.entry}
|
||||
stopLoss: ${order.stopLoss}
|
||||
volume: ${order.volume}
|
||||
reason: ${reason}
|
||||
`);
|
||||
console.log(`Sell ${symbol} ${interval}M ${reason} ${candles[0].time}`);
|
||||
},
|
||||
onEvent: (data: any, eventType: string) => {
|
||||
sendMessage(`${eventType} ${symbol} ${interval}M ${data.close}`)
|
||||
console.log(`${eventType} ${symbol} ${interval}M ${JSON.stringify(data)}`);
|
||||
},
|
||||
});
|
||||
analysis.symbol = symbol;
|
||||
analysis.interval = interval;
|
||||
console.log(analysis);
|
||||
await indicatorService.upsertWave({
|
||||
symbol,
|
||||
interval,
|
||||
trend: analysis.emaDirection as 'Bullish' | 'Bearish',
|
||||
numberTouchEma: analysis.numberTouch200,
|
||||
numberMacdCrossUp: analysis.numberMacdCrossUp,
|
||||
numberMacdCrossDown: analysis.numberMacdCrossDown,
|
||||
lowOrHighPrice: analysis.lowHight,
|
||||
});
|
||||
}
|
||||
|
||||
schedule.scheduleJob(rule, () => {
|
||||
// Có thể lặp qua nhiều symbol/interval nếu muốn
|
||||
analyzeCandlesJob('BTCUSDT', '5');
|
||||
});
|
||||
export function createCandleAnalysisSchedule(symbol: string, interval: KlineIntervalV3) {
|
||||
const rule = new schedule.RecurrenceRule();
|
||||
rule.tz = 'Asia/Ho_Chi_Minh';
|
||||
switch (interval) {
|
||||
case '5':
|
||||
rule.minute = [4, 9, 14, 19, 24, 29, 34, 39, 44, 49, 54, 59];
|
||||
break;
|
||||
case '15':
|
||||
rule.minute = [14, 29, 44, 59];
|
||||
break;
|
||||
case '30':
|
||||
rule.minute = [29, 59];
|
||||
break;
|
||||
case '60':
|
||||
rule.minute = [59];
|
||||
break;
|
||||
case '240':
|
||||
rule.minute = [59];
|
||||
rule.hour = [2, 6, 10, 14, 18, 22];
|
||||
break;
|
||||
case 'D':
|
||||
rule.minute = [59];
|
||||
rule.hour = [6];
|
||||
break;
|
||||
}
|
||||
rule.second = 59;
|
||||
schedule.scheduleJob(rule, () => {
|
||||
analyzeCandlesJob(symbol, interval);
|
||||
});
|
||||
}
|
||||
8
src/schedule/index.ts
Normal file
8
src/schedule/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { createCandleAnalysisSchedule } from "./candleAnalysisSchedule";
|
||||
|
||||
createCandleAnalysisSchedule("ETHUSDT", "15");
|
||||
createCandleAnalysisSchedule("BTCUSDT", "15");
|
||||
createCandleAnalysisSchedule("ETHUSDT", "60");
|
||||
createCandleAnalysisSchedule("BTCUSDT", "60");
|
||||
createCandleAnalysisSchedule("ETHUSDT", "240");
|
||||
createCandleAnalysisSchedule("BTCUSDT", "240");
|
||||
|
|
@ -1,11 +1,33 @@
|
|||
import { EMA, MACD } from "technicalindicators";
|
||||
import { Candle } from "../dao/candles";
|
||||
import { analyzeCandleSequence } from "../helpers/candles";
|
||||
import { analyzeCandleSequence, isHighVolatilityCandle, isPinBar } from "../helpers/candles";
|
||||
import { Analysis } from "../dao/analysis";
|
||||
import { KlineIntervalV3 } from "bybit-api";
|
||||
import { Order } from "../dao/order";
|
||||
import { supabase } from "./supabaseService";
|
||||
import { Wave } from "../dao/wave";
|
||||
|
||||
export interface EventHandler {
|
||||
onBuy: (candle: Order, reason: string) => void;
|
||||
onSell: (candle: Order, reason: string) => void;
|
||||
onEvent: (data: any, eventType: string) => void;
|
||||
}
|
||||
|
||||
export class IndicatorService {
|
||||
constructor() {}
|
||||
|
||||
analyze(candles: Candle[]): { ema34: number; ema200: number; macd: any } {
|
||||
analyze(candles: Candle[], wave: Wave): Analysis {
|
||||
if (!wave) {
|
||||
wave = {
|
||||
symbol: "",
|
||||
interval: "",
|
||||
trend: "Bullish",
|
||||
numberTouchEma: 0,
|
||||
numberMacdCrossUp: 0,
|
||||
numberMacdCrossDown: 0,
|
||||
lowOrHighPrice: 0,
|
||||
};
|
||||
}
|
||||
let close = candles.map((c) => c.close);
|
||||
const ema34 = EMA.calculate({
|
||||
period: 34,
|
||||
|
|
@ -27,28 +49,213 @@ export class IndicatorService {
|
|||
reversedInput: true,
|
||||
});
|
||||
const candle = candles[0];
|
||||
|
||||
const analysis: Analysis = {
|
||||
symbol: wave.symbol,
|
||||
interval: wave.interval,
|
||||
emaDirection: "",
|
||||
macdDirection: "",
|
||||
isMacdCrossUp: false,
|
||||
isMacdCrossDown: false,
|
||||
isEmaCrossUp: false,
|
||||
isEmaCrossDown: false,
|
||||
isPinBar: false,
|
||||
isHighVolatility: false,
|
||||
isBuy: false,
|
||||
isSell: false,
|
||||
isTouch200: false,
|
||||
isReverse200: false,
|
||||
lowHight: wave.lowOrHighPrice,
|
||||
numberTouch200: wave.numberTouchEma,
|
||||
numberMacdCrossUp: wave.numberMacdCrossUp,
|
||||
numberMacdCrossDown: wave.numberMacdCrossDown,
|
||||
isMacdUpper: false,
|
||||
isMacdLower: false,
|
||||
};
|
||||
|
||||
if (ema34[0] > ema200[0]) {
|
||||
if (wave.trend === "Bearish") {
|
||||
analysis.numberTouch200 = 0;
|
||||
}
|
||||
analysis.emaDirection = "Bullish";
|
||||
} else {
|
||||
if (wave.trend === "Bullish") {
|
||||
analysis.numberTouch200 = 0;
|
||||
}
|
||||
analysis.emaDirection = "Bearish";
|
||||
}
|
||||
|
||||
if (macd[0].MACD! > macd[0].signal!) {
|
||||
if (wave.trend === "Bearish") {
|
||||
analysis.numberMacdCrossUp = 0;
|
||||
analysis.numberMacdCrossDown = 0;
|
||||
}
|
||||
analysis.macdDirection = "Bullish";
|
||||
} else {
|
||||
if (wave.trend === "Bullish") {
|
||||
analysis.numberMacdCrossDown = 0;
|
||||
analysis.numberMacdCrossUp = 0;
|
||||
}
|
||||
analysis.macdDirection = "Bearish";
|
||||
}
|
||||
|
||||
if (analysis.emaDirection === "Bullish") {
|
||||
if (analysis.lowHight < candle.high) {
|
||||
analysis.lowHight = candle.high;
|
||||
}
|
||||
} else {
|
||||
if (analysis.lowHight > candle.low) {
|
||||
analysis.lowHight = candle.low;
|
||||
}
|
||||
}
|
||||
|
||||
if (macd[0].MACD! > 0) {
|
||||
analysis.isMacdUpper = true;
|
||||
} else {
|
||||
analysis.isMacdLower = true;
|
||||
}
|
||||
|
||||
if (macd[0].MACD! > macd[0].signal! && macd[1].MACD! < macd[1].signal!) {
|
||||
analysis.isMacdCrossUp = true;
|
||||
analysis.numberMacdCrossUp++;
|
||||
} else if (macd[0].MACD! < macd[0].signal! && macd[1].MACD! > macd[1].signal!) {
|
||||
analysis.isMacdCrossDown = true;
|
||||
analysis.numberMacdCrossDown++;
|
||||
}
|
||||
|
||||
if (ema34[0] > ema200[0] && ema34[1] < ema200[1]) {
|
||||
analysis.isEmaCrossUp = true;
|
||||
} else if (ema34[0] < ema200[0] && ema34[1] > ema200[1]) {
|
||||
analysis.isEmaCrossDown = true;
|
||||
}
|
||||
|
||||
if (isPinBar(candle).isPinBar) {
|
||||
analysis.isPinBar = true;
|
||||
}
|
||||
|
||||
if (isHighVolatilityCandle(candles.slice(0, 50).reverse())) {
|
||||
analysis.isHighVolatility = true;
|
||||
}
|
||||
|
||||
if (
|
||||
candle.low < ema200[0] &&
|
||||
candle.high > ema200[0] &&
|
||||
candle.close > ema200[0]
|
||||
candle.close > ema200[0] &&
|
||||
candle.open > ema200[0] &&
|
||||
analysis.emaDirection === "Bullish"
|
||||
) {
|
||||
console.log("Buy");
|
||||
analysis.numberTouch200++;
|
||||
analysis.isTouch200 = true;
|
||||
}
|
||||
|
||||
if (
|
||||
candle.high > ema200[0] &&
|
||||
candle.low < ema200[0] &&
|
||||
candle.close < ema200[0]
|
||||
candle.close < ema200[0] &&
|
||||
candle.open < ema200[0] &&
|
||||
analysis.emaDirection === "Bearish"
|
||||
) {
|
||||
console.log("Sell");
|
||||
analysis.numberTouch200++;
|
||||
analysis.isTouch200 = true;
|
||||
}
|
||||
|
||||
const candleAnalysis = analyzeCandleSequence([
|
||||
candles[2],
|
||||
candles[1],
|
||||
candles[0],
|
||||
]);
|
||||
if (candle.open < ema200[0] && candle.close > ema200[0] && analysis.emaDirection === "Bullish") {
|
||||
const candlesCheck = [candles[3], candles[2], candles[1], candles[0]];
|
||||
const ema200Check = [ema200[3], ema200[2], ema200[1], ema200[0]];
|
||||
const candleCheck = candlesCheck.find(
|
||||
(c, i) => c.close < ema200Check[i] && c.open > ema200Check[i]
|
||||
);
|
||||
if (candleCheck) {
|
||||
analysis.numberTouch200++;
|
||||
analysis.isReverse200 = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { ema34: ema34[0], ema200: ema200[0], macd: macd[0] };
|
||||
if (candle.open > ema200[0] && candle.close < ema200[0] && analysis.emaDirection === "Bearish") {
|
||||
const candlesCheck = [candles[3], candles[2], candles[1], candles[0]];
|
||||
const ema200Check = [ema200[3], ema200[2], ema200[1], ema200[0]];
|
||||
const candleCheck = candlesCheck.find(
|
||||
(c, i) => c.close > ema200Check[i] && c.open < ema200Check[i]
|
||||
);
|
||||
if (candleCheck) {
|
||||
analysis.numberTouch200++;
|
||||
analysis.isReverse200 = true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(analysis);
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
makeOrder(analysis: Analysis, candles: Candle[], side: "buy" | "sell"): Order {
|
||||
const last10Candles = candles.slice(0, 12);
|
||||
const lowestPrice = last10Candles.reduce((min, c) => Math.min(min, c.low), Number.MAX_SAFE_INTEGER);
|
||||
const highestPrice = last10Candles.reduce((max, c) => Math.max(max, c.high), Number.MIN_SAFE_INTEGER);
|
||||
const order: Order = {
|
||||
symbol: analysis.symbol,
|
||||
side,
|
||||
entry: candles[0].close,
|
||||
stopLoss: side === "buy" ? lowestPrice : highestPrice,
|
||||
volume: 1,
|
||||
};
|
||||
return order;
|
||||
}
|
||||
|
||||
handleEvent(analysis: Analysis, candles: Candle[], eventHandler: EventHandler) {
|
||||
if (analysis.isTouch200 && analysis.emaDirection === "Bullish") {
|
||||
const order = this.makeOrder(analysis, candles, "buy");
|
||||
eventHandler.onBuy(order, "Follow trend EMA Touch 200");
|
||||
}
|
||||
if (analysis.isTouch200 && analysis.emaDirection === "Bearish") {
|
||||
const order = this.makeOrder(analysis, candles, "sell");
|
||||
eventHandler.onSell(order, "Follow trend EMA Touch 200");
|
||||
}
|
||||
if (analysis.isMacdCrossUp && analysis.isMacdUpper) {
|
||||
const order = this.makeOrder(analysis, candles, "buy");
|
||||
eventHandler.onBuy(order, "Follow trend MACD Cross Up");
|
||||
}
|
||||
if (analysis.isMacdCrossDown && analysis.isMacdLower) {
|
||||
const order = this.makeOrder(analysis, candles, "sell");
|
||||
eventHandler.onSell(order, "Follow trend MACD Cross Down");
|
||||
}
|
||||
|
||||
if (analysis.isMacdCrossUp && analysis.isMacdLower && analysis.numberMacdCrossUp >= 2) {
|
||||
const order = this.makeOrder(analysis, candles, "buy");
|
||||
eventHandler.onBuy(order, "Counter trend rủi ro cao MACD Cross Up");
|
||||
}
|
||||
if (analysis.isMacdCrossDown && analysis.isMacdUpper && analysis.numberMacdCrossDown >= 2) {
|
||||
const order = this.makeOrder(analysis, candles, "sell");
|
||||
eventHandler.onSell(order, "Counter trend rủi ro cao MACD Cross Down");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy bản ghi wave theo symbol và interval
|
||||
*/
|
||||
async getWave(symbol: string, interval: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('waves')
|
||||
.select('*')
|
||||
.eq('symbol', symbol)
|
||||
.eq('interval', interval)
|
||||
.single();
|
||||
if (error) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hoặc Insert wave theo symbol và interval
|
||||
*/
|
||||
async upsertWave(wave: Wave) {
|
||||
const { data, error } = await supabase
|
||||
.from('waves')
|
||||
.upsert([
|
||||
wave
|
||||
], { onConflict: 'symbol,interval' });
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase: SupabaseClient = createClient(
|
||||
export const supabase: SupabaseClient = createClient(
|
||||
process.env.SUPABASE_URL || '',
|
||||
process.env.SUPABASE_KEY || ''
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue