추세검색 화면 적용
This commit is contained in:
@@ -24,10 +24,17 @@ import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
|
||||
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
||||
import { resolveBbBandBackground } from './bollingerConfig';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
||||
import { formatChartAxisPrice } from './dataGenerator';
|
||||
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
||||
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
||||
|
||||
const MAIN_PRICE_FORMAT = {
|
||||
type: 'custom' as const,
|
||||
minMove: 0.001,
|
||||
formatter: formatChartAxisPrice,
|
||||
};
|
||||
import {
|
||||
getIchimokuPlotTitle,
|
||||
isIchimokuCloudVisible,
|
||||
@@ -378,26 +385,30 @@ export class ChartManager {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
priceFormat: MAIN_PRICE_FORMAT,
|
||||
});
|
||||
case 'bar':
|
||||
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor });
|
||||
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT });
|
||||
case 'line':
|
||||
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true });
|
||||
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT });
|
||||
case 'area':
|
||||
return this.chart.addSeries(AreaSeries, {
|
||||
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
|
||||
priceFormat: MAIN_PRICE_FORMAT,
|
||||
});
|
||||
case 'baseline':
|
||||
return this.chart.addSeries(BaselineSeries, {
|
||||
baseValue: { type: 'price', price: 0 },
|
||||
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
|
||||
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
|
||||
priceFormat: MAIN_PRICE_FORMAT,
|
||||
});
|
||||
default:
|
||||
return this.chart.addSeries(CandlestickSeries, {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
priceFormat: MAIN_PRICE_FORMAT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,7 +430,7 @@ export class ChartManager {
|
||||
const timeFormatter = (time: Time) =>
|
||||
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
|
||||
this.chart.applyOptions({
|
||||
localization: { timeFormatter },
|
||||
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
|
||||
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1124,3 +1124,112 @@ export async function pinCandleWatch(market: string, candleType: string): Promis
|
||||
/* pin 실패해도 평가 API는 시도 */
|
||||
}
|
||||
}
|
||||
|
||||
// ── 추세검색 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TrendSearchConditionDto {
|
||||
id: string;
|
||||
category: string;
|
||||
label: string;
|
||||
currentValue: number | null;
|
||||
thresholdLabel: string;
|
||||
progress: number;
|
||||
status: 'match' | 'pending' | 'mismatch';
|
||||
}
|
||||
|
||||
export interface TrendSearchResultDto {
|
||||
market: string;
|
||||
koreanName: string;
|
||||
currentPrice: number;
|
||||
changeRate: number;
|
||||
matchRate: number;
|
||||
matchedCount: number;
|
||||
totalConditions: number;
|
||||
highMatch: boolean;
|
||||
conditions: TrendSearchConditionDto[];
|
||||
}
|
||||
|
||||
export interface TrendSearchRequest {
|
||||
timeframe: string;
|
||||
limit: number;
|
||||
scanLimit: number;
|
||||
sortBy: string;
|
||||
newHighDays: number;
|
||||
priceEnabled: boolean;
|
||||
volumeEnabled: boolean;
|
||||
flowEnabled: boolean;
|
||||
fundamentalEnabled: boolean;
|
||||
near52wHigh: boolean;
|
||||
ret3m: boolean;
|
||||
ret6m: boolean;
|
||||
maDeviation: boolean;
|
||||
maFullAlign: boolean;
|
||||
stage2: boolean;
|
||||
relativeStrength: boolean;
|
||||
newHighCount: boolean;
|
||||
tradeAmount: boolean;
|
||||
volVs5dAvg: boolean;
|
||||
turnover: boolean;
|
||||
bidAskRatio: boolean;
|
||||
smartMoney: boolean;
|
||||
instConsecutive: boolean;
|
||||
rsiHigh: boolean;
|
||||
macdPeak: boolean;
|
||||
lightCredit: boolean;
|
||||
earningsGrowth: boolean;
|
||||
roe: boolean;
|
||||
opMargin: boolean;
|
||||
pegBelow1: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
|
||||
timeframe: '1d',
|
||||
limit: 20,
|
||||
scanLimit: 80,
|
||||
sortBy: 'near52wHigh',
|
||||
newHighDays: 20,
|
||||
priceEnabled: true,
|
||||
volumeEnabled: true,
|
||||
flowEnabled: true,
|
||||
fundamentalEnabled: false,
|
||||
near52wHigh: true,
|
||||
ret3m: false,
|
||||
ret6m: false,
|
||||
maDeviation: true,
|
||||
maFullAlign: true,
|
||||
stage2: false,
|
||||
relativeStrength: true,
|
||||
newHighCount: false,
|
||||
tradeAmount: false,
|
||||
volVs5dAvg: true,
|
||||
turnover: false,
|
||||
bidAskRatio: false,
|
||||
smartMoney: false,
|
||||
instConsecutive: false,
|
||||
rsiHigh: true,
|
||||
macdPeak: false,
|
||||
lightCredit: false,
|
||||
earningsGrowth: false,
|
||||
roe: false,
|
||||
opMargin: false,
|
||||
pegBelow1: false,
|
||||
};
|
||||
|
||||
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
|
||||
|
||||
export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> {
|
||||
return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTrendSearchDetail(
|
||||
market: string,
|
||||
timeframe?: string,
|
||||
): Promise<TrendSearchResultDto | null> {
|
||||
const params = new URLSearchParams({ market });
|
||||
if (timeframe) params.set('timeframe', timeframe);
|
||||
return request<TrendSearchResultDto>(`/trend-search/detail?${params}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ export function formatPrice(price: number): string {
|
||||
return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
/** 실시간 차트 우측 가격축·크로스헤어 라벨 (10만원 미만: 소수 3자리, 이상: 정수) */
|
||||
export function formatChartAxisPrice(price: number): string {
|
||||
if (!Number.isFinite(price)) return String(price);
|
||||
if (Math.abs(price) >= 100_000) {
|
||||
return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
}
|
||||
return price.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
export function formatTime(ts: number, timeframe: Timeframe): string {
|
||||
return formatUnixForChart(ts, timeframe);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
chart: '실시간차트',
|
||||
paper: '모의투자',
|
||||
virtual: '가상투자',
|
||||
'trend-search': '추세검색',
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 추세검색 API
|
||||
*/
|
||||
import { scanTrendSearch as apiScan, fetchTrendSearchDetail as apiDetail } from './backendApi';
|
||||
|
||||
export type {
|
||||
TrendSearchConditionDto,
|
||||
TrendSearchResultDto,
|
||||
TrendSearchRequest,
|
||||
} from './backendApi';
|
||||
|
||||
export { DEFAULT_TREND_SEARCH_REQUEST, TREND_TIMEFRAMES } from './backendApi';
|
||||
|
||||
export async function scanTrendSearch(body: import('./backendApi').TrendSearchRequest) {
|
||||
return apiScan(body);
|
||||
}
|
||||
|
||||
export async function fetchTrendSearchDetail(market: string, timeframe?: string) {
|
||||
return apiDetail(market, timeframe);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/** 추세검색 20개 조건 정의 */
|
||||
export type TrendConditionMode = 'sort' | 'filter' | 'unsupported';
|
||||
|
||||
export interface TrendConditionDef {
|
||||
id: string;
|
||||
category: 'price' | 'volume' | 'flow' | 'fundamental';
|
||||
categoryLabel: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
mode: TrendConditionMode;
|
||||
requestKey: keyof import('./backendApi').TrendSearchRequest;
|
||||
categoryKey: keyof Pick<
|
||||
import('./backendApi').TrendSearchRequest,
|
||||
'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled'
|
||||
>;
|
||||
}
|
||||
|
||||
export const TREND_CONDITION_DEFS: TrendConditionDef[] = [
|
||||
{
|
||||
id: 'near52wHigh',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '52주 신고가 근접률',
|
||||
desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)',
|
||||
mode: 'sort',
|
||||
requestKey: 'near52wHigh',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'ret3m',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '3개월 수익률',
|
||||
desc: '최근 12주 누적 상승률',
|
||||
mode: 'sort',
|
||||
requestKey: 'ret3m',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'ret6m',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '6개월 수익률',
|
||||
desc: '최근 24주 누적 상승률',
|
||||
mode: 'sort',
|
||||
requestKey: 'ret6m',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'maDeviation',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '이동평균 이격도 (20·60일)',
|
||||
desc: '20·60일선 대비 주가 이격률',
|
||||
mode: 'sort',
|
||||
requestKey: 'maDeviation',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'maFullAlign',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '이동평균 완전 정배열',
|
||||
desc: '주가 > 5 > 20 > 60 > 120 > 200일선',
|
||||
mode: 'filter',
|
||||
requestKey: 'maFullAlign',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'stage2',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: "마크 미너비니 Stage 2",
|
||||
desc: '150·200일선 위 + 200일선 1개월↑',
|
||||
mode: 'filter',
|
||||
requestKey: 'stage2',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'relativeStrength',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '상대강도 (RS vs BTC)',
|
||||
desc: 'BTC 대비 초과 수익률',
|
||||
mode: 'sort',
|
||||
requestKey: 'relativeStrength',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'newHighCount',
|
||||
category: 'price',
|
||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
||||
label: '최근 N일 신고가 달성 횟수',
|
||||
desc: '최근 구간 신고가 경신 일수',
|
||||
mode: 'sort',
|
||||
requestKey: 'newHighCount',
|
||||
categoryKey: 'priceEnabled',
|
||||
},
|
||||
{
|
||||
id: 'tradeAmount',
|
||||
category: 'volume',
|
||||
categoryLabel: 'Ⅱ. 거래량·대금',
|
||||
label: '당일 거래대금',
|
||||
desc: '24h 누적 거래대금',
|
||||
mode: 'sort',
|
||||
requestKey: 'tradeAmount',
|
||||
categoryKey: 'volumeEnabled',
|
||||
},
|
||||
{
|
||||
id: 'volVs5dAvg',
|
||||
category: 'volume',
|
||||
categoryLabel: 'Ⅱ. 거래량·대금',
|
||||
label: '5일 평균 대비 거래대금 증가율',
|
||||
desc: '5일 평균 대비 당일 거래대금',
|
||||
mode: 'sort',
|
||||
requestKey: 'volVs5dAvg',
|
||||
categoryKey: 'volumeEnabled',
|
||||
},
|
||||
{
|
||||
id: 'turnover',
|
||||
category: 'volume',
|
||||
categoryLabel: 'Ⅱ. 거래량·대금',
|
||||
label: '거래량 회전율',
|
||||
desc: '20일 평균 대비 당일 거래량 비율',
|
||||
mode: 'sort',
|
||||
requestKey: 'turnover',
|
||||
categoryKey: 'volumeEnabled',
|
||||
},
|
||||
{
|
||||
id: 'bidAskRatio',
|
||||
category: 'volume',
|
||||
categoryLabel: 'Ⅱ. 거래량·대금',
|
||||
label: '매수 대기 잔량 비율',
|
||||
desc: '호가 매수잔량 / 매도잔량',
|
||||
mode: 'sort',
|
||||
requestKey: 'bidAskRatio',
|
||||
categoryKey: 'volumeEnabled',
|
||||
},
|
||||
{
|
||||
id: 'smartMoney',
|
||||
category: 'flow',
|
||||
categoryLabel: 'Ⅲ. 수급·심리',
|
||||
label: '스마트머니 누적 (OBV)',
|
||||
desc: 'OBV 20일 누적 상승폭 (기관·외국인 대용)',
|
||||
mode: 'sort',
|
||||
requestKey: 'smartMoney',
|
||||
categoryKey: 'flowEnabled',
|
||||
},
|
||||
{
|
||||
id: 'instConsecutive',
|
||||
category: 'flow',
|
||||
categoryLabel: 'Ⅲ. 수급·심리',
|
||||
label: '연속 순매수 일수',
|
||||
desc: '양봉+거래량 증가 연속 일수 (연기금·투신 대용)',
|
||||
mode: 'sort',
|
||||
requestKey: 'instConsecutive',
|
||||
categoryKey: 'flowEnabled',
|
||||
},
|
||||
{
|
||||
id: 'rsiHigh',
|
||||
category: 'flow',
|
||||
categoryLabel: 'Ⅲ. 수급·심리',
|
||||
label: 'RSI (상대강도지수)',
|
||||
desc: 'RSI 14 — 높을수록 강한 모멘텀',
|
||||
mode: 'sort',
|
||||
requestKey: 'rsiHigh',
|
||||
categoryKey: 'flowEnabled',
|
||||
},
|
||||
{
|
||||
id: 'macdPeak',
|
||||
category: 'flow',
|
||||
categoryLabel: 'Ⅲ. 수급·심리',
|
||||
label: 'MACD 오실레이터 최고치',
|
||||
desc: '최근 20일 MACD 히스토그램 신고가',
|
||||
mode: 'sort',
|
||||
requestKey: 'macdPeak',
|
||||
categoryKey: 'flowEnabled',
|
||||
},
|
||||
{
|
||||
id: 'lightCredit',
|
||||
category: 'flow',
|
||||
categoryLabel: 'Ⅲ. 수급·심리',
|
||||
label: '거래량 감소 + 주가 상승',
|
||||
desc: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)',
|
||||
mode: 'filter',
|
||||
requestKey: 'lightCredit',
|
||||
categoryKey: 'flowEnabled',
|
||||
},
|
||||
{
|
||||
id: 'earningsGrowth',
|
||||
category: 'fundamental',
|
||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
||||
label: '분기 영업이익 성장률 (YoY)',
|
||||
desc: '주식 전용 — 코인 미지원',
|
||||
mode: 'unsupported',
|
||||
requestKey: 'earningsGrowth',
|
||||
categoryKey: 'fundamentalEnabled',
|
||||
},
|
||||
{
|
||||
id: 'roe',
|
||||
category: 'fundamental',
|
||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
||||
label: 'ROE (자기자본이익률)',
|
||||
desc: '주식 전용 — 코인 미지원',
|
||||
mode: 'unsupported',
|
||||
requestKey: 'roe',
|
||||
categoryKey: 'fundamentalEnabled',
|
||||
},
|
||||
{
|
||||
id: 'opMargin',
|
||||
category: 'fundamental',
|
||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
||||
label: '매출액 영업이익률',
|
||||
desc: '주식 전용 — 코인 미지원',
|
||||
mode: 'unsupported',
|
||||
requestKey: 'opMargin',
|
||||
categoryKey: 'fundamentalEnabled',
|
||||
},
|
||||
{
|
||||
id: 'pegBelow1',
|
||||
category: 'fundamental',
|
||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
||||
label: 'PEG 1.0 이하',
|
||||
desc: '주식 전용 — 코인 미지원',
|
||||
mode: 'unsupported',
|
||||
requestKey: 'pegBelow1',
|
||||
categoryKey: 'fundamentalEnabled',
|
||||
},
|
||||
];
|
||||
|
||||
export const TREND_SORT_CONDITION_IDS = TREND_CONDITION_DEFS
|
||||
.filter(c => c.mode === 'sort')
|
||||
.map(c => c.id);
|
||||
|
||||
export const TREND_CATEGORY_ORDER = [
|
||||
{ key: 'priceEnabled' as const, label: 'Ⅰ. 가격·탄력성', cat: 'price' as const },
|
||||
{ key: 'volumeEnabled' as const, label: 'Ⅱ. 거래량·대금', cat: 'volume' as const },
|
||||
{ key: 'flowEnabled' as const, label: 'Ⅲ. 수급·심리', cat: 'flow' as const },
|
||||
{ key: 'fundamentalEnabled' as const, label: 'Ⅳ. 펀더멘털', cat: 'fundamental' as const },
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TrendSearchConditionDto } from './trendSearchApi';
|
||||
import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics';
|
||||
import type { VirtualConditionRow } from './virtualStrategyConditions';
|
||||
|
||||
export function buildTrendSearchMetrics(conditions: TrendSearchConditionDto[]): ConditionMetric[] {
|
||||
return conditions.map(c => {
|
||||
const status: ConditionStatus = c.status === 'match'
|
||||
? 'match'
|
||||
: c.status === 'pending'
|
||||
? 'pending'
|
||||
: 'nomatch';
|
||||
|
||||
const row: VirtualConditionRow & { currentValue: number | null } = {
|
||||
id: c.id,
|
||||
indicatorType: c.category,
|
||||
displayName: c.label,
|
||||
conditionType: 'GTE',
|
||||
conditionLabel: c.label,
|
||||
targetValue: null,
|
||||
timeframe: '1d',
|
||||
side: 'buy',
|
||||
plotKey: c.id,
|
||||
satisfied: c.status === 'match' ? true : c.status === 'mismatch' ? false : null,
|
||||
thresholdLabel: c.thresholdLabel,
|
||||
currentValue: c.currentValue ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
row,
|
||||
status,
|
||||
matchRate: c.progress,
|
||||
progress: c.progress,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function tfLabelShort(tf: string): string {
|
||||
if (tf === '1M') return '1M';
|
||||
if (tf.endsWith('m')) return `${tf.replace('m', '')}분`;
|
||||
if (tf.endsWith('h')) return `${tf.replace('h', '')}시간`;
|
||||
if (tf.endsWith('d')) return `${tf.replace('d', '')}일`;
|
||||
if (tf.endsWith('w')) return `${tf.replace('w', '')}주`;
|
||||
return tf;
|
||||
}
|
||||
Reference in New Issue
Block a user