직접입력 평가로직 오류
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.ta4j.core.BarSeries;
|
||||||
|
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||||
|
import org.ta4j.core.Rule;
|
||||||
|
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||||
|
import org.ta4j.core.num.DoubleNumFactory;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSI CROSS_UP — HL_MID(중앙선) vs K_50(직접입력) 동일 임계값 시그널 일치.
|
||||||
|
*/
|
||||||
|
class RsiThresholdCrossSignalTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private StrategyDslToTa4jAdapter adapter;
|
||||||
|
private BarSeries series;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new StrategyDslToTa4jAdapter();
|
||||||
|
series = buildOscillatingSeries(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rsiCrossUp_hlMid_equalsK50Override() throws Exception {
|
||||||
|
assertSignalHitsEqual(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "RSI_VALUE_9",
|
||||||
|
"rightField": "HL_MID",
|
||||||
|
"period": 9,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"thresholdOverride": false,
|
||||||
|
"candleRange": 1
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "RSI_VALUE_9",
|
||||||
|
"rightField": "K_50",
|
||||||
|
"period": 9,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"thresholdOverride": true,
|
||||||
|
"targetValue": 50,
|
||||||
|
"candleRange": 1
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rsiCrossUp_hlMid_equalsK50WithoutOverride() throws Exception {
|
||||||
|
assertSignalHitsEqual(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "RSI_VALUE_9",
|
||||||
|
"rightField": "HL_MID",
|
||||||
|
"period": 9,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"candleRange": 1
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"conditionType": "CROSS_UP",
|
||||||
|
"leftField": "RSI_VALUE_9",
|
||||||
|
"rightField": "K_50",
|
||||||
|
"period": 9,
|
||||||
|
"valuePeriodOverride": true,
|
||||||
|
"candleRange": 1
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertSignalHitsEqual(String condA, String condB) throws Exception {
|
||||||
|
Map<String, Map<String, Object>> indicatorParams = Map.of(
|
||||||
|
"RSI", Map.of("length", 9, "maLength", 3, "maType", "SMA", "src", "close"));
|
||||||
|
Map<String, Map<String, Object>> visual = Map.of(
|
||||||
|
"RSI", Map.of("hlines", List.of(
|
||||||
|
Map.of("label", "과열선", "price", 70),
|
||||||
|
Map.of("label", "중앙선", "price", 50),
|
||||||
|
Map.of("label", "침체선", "price", 30))));
|
||||||
|
|
||||||
|
List<Integer> hitsA = scanHits(condA, indicatorParams, visual);
|
||||||
|
List<Integer> hitsB = scanHits(condB, indicatorParams, visual);
|
||||||
|
assertEquals(hitsA, hitsB,
|
||||||
|
"HL_MID and K_50 should produce identical CROSS_UP bars, A=" + hitsA.size() + " B=" + hitsB.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> scanHits(String condJson,
|
||||||
|
Map<String, Map<String, Object>> indicatorParams,
|
||||||
|
Map<String, Map<String, Object>> visual) throws Exception {
|
||||||
|
JsonNode buyDsl = MAPPER.readTree("""
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleTypes": ["3m"],
|
||||||
|
"candleType": "3m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": %s
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(condJson.trim()));
|
||||||
|
|
||||||
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||||
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||||
|
series, indicatorParams, visual, null, null, false, Map.of());
|
||||||
|
Rule rule = adapter.toRule(buyDsl, ctx);
|
||||||
|
|
||||||
|
java.util.ArrayList<Integer> hits = new java.util.ArrayList<>();
|
||||||
|
for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) {
|
||||||
|
if (rule.isSatisfied(i, null)) hits.add(i);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BarSeries buildOscillatingSeries(int count) {
|
||||||
|
BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder()
|
||||||
|
.withName("test")
|
||||||
|
.withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(3)))
|
||||||
|
.withNumFactory(DoubleNumFactory.getInstance());
|
||||||
|
BarSeries s = builder.build();
|
||||||
|
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||||
|
double price = 100;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double swing = Math.sin(i * 0.15) * 8 + Math.sin(i * 0.04) * 4;
|
||||||
|
price = 100 + swing + (i * 0.02);
|
||||||
|
double o = price - 0.5;
|
||||||
|
double h = price + 1.5;
|
||||||
|
double l = price - 1.5;
|
||||||
|
double c = price;
|
||||||
|
s.addBar(t, o, h, l, c, 1000);
|
||||||
|
t = t.plus(Duration.ofMinutes(3));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ export default function ComboFieldSelect({
|
|||||||
if (raw === COMBO_FIELD_CUSTOM) {
|
if (raw === COMBO_FIELD_CUSTOM) {
|
||||||
if (canCustom) {
|
if (canCustom) {
|
||||||
setMode('custom');
|
setMode('custom');
|
||||||
onCustomNumberChange(customNumber);
|
|
||||||
setDraft(String(customNumber));
|
setDraft(String(customNumber));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
isThresholdFieldOrSymbol,
|
isThresholdFieldOrSymbol,
|
||||||
isThresholdSymbol,
|
isThresholdSymbol,
|
||||||
|
inferThresholdRoleForValue,
|
||||||
resolveInheritedThresholdField,
|
resolveInheritedThresholdField,
|
||||||
resolveThresholdFromDef,
|
resolveThresholdFromDef,
|
||||||
THRESHOLD_HL_MID,
|
THRESHOLD_HL_MID,
|
||||||
@@ -282,6 +283,14 @@ export function setConditionThreshold(
|
|||||||
? getChartReferenceThreshold(cond, side, inheritedField, def)
|
? getChartReferenceThreshold(cond, side, inheritedField, def)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const matchedRole = def
|
||||||
|
? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh)
|
||||||
|
: null;
|
||||||
|
if (matchedRole != null) {
|
||||||
|
const { targetValue: _t, ...rest } = cond;
|
||||||
|
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (chartRef != null && Math.abs(chartRef - value) < 0.0001) {
|
if (chartRef != null && Math.abs(chartRef - value) < 0.0001) {
|
||||||
const role = isThresholdSymbol(inheritedField)
|
const role = isThresholdSymbol(inheritedField)
|
||||||
? inheritedField
|
? inheritedField
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './conditionPeriods';
|
} from './conditionPeriods';
|
||||||
import {
|
import {
|
||||||
defaultInheritedThresholdField,
|
defaultInheritedThresholdField,
|
||||||
|
inferThresholdRoleForValue,
|
||||||
inferThresholdSymbolFromLegacy,
|
inferThresholdSymbolFromLegacy,
|
||||||
isThresholdFieldOrSymbol,
|
isThresholdFieldOrSymbol,
|
||||||
isThresholdSymbol,
|
isThresholdSymbol,
|
||||||
@@ -95,13 +96,33 @@ export function migrateLogicRootForEditor(
|
|||||||
return repairPriceExtremePairForest(migrated.root, migrated.orphans);
|
return repairPriceExtremePairForest(migrated.root, migrated.orphans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */
|
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거, 직접입력은 hline 역할로 등가 정규화 */
|
||||||
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
|
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
|
||||||
let next: ConditionDSL = { ...cond };
|
let next: ConditionDSL = { ...cond };
|
||||||
|
|
||||||
|
if (isThresholdOverridden(next)) {
|
||||||
|
if (next.targetValue != null && Number.isFinite(next.targetValue)) {
|
||||||
|
const role = inferThresholdRoleForValue(next.targetValue, next.indicatorType, {});
|
||||||
|
if (role != null) {
|
||||||
|
const { targetValue: _t, ...rest } = next;
|
||||||
|
next = { ...rest, rightField: role, thresholdOverride: false };
|
||||||
|
}
|
||||||
|
} else if (next.rightField?.startsWith('K_')) {
|
||||||
|
const role = inferThresholdSymbolFromLegacy(next.rightField, next.indicatorType, {});
|
||||||
|
if (role && isThresholdSymbol(role)) {
|
||||||
|
const { targetValue: _t, ...rest } = next;
|
||||||
|
next = { ...rest, rightField: role, thresholdOverride: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isThresholdOverridden(next)) {
|
if (!isThresholdOverridden(next)) {
|
||||||
if (next.rightField?.startsWith('K_')) {
|
if (next.rightField?.startsWith('K_')) {
|
||||||
next.rightField = defaultInheritedThresholdField(next.indicatorType);
|
next.rightField = inferThresholdSymbolFromLegacy(
|
||||||
|
next.rightField,
|
||||||
|
next.indicatorType,
|
||||||
|
{},
|
||||||
|
) ?? defaultInheritedThresholdField(next.indicatorType);
|
||||||
}
|
}
|
||||||
if (!isThresholdSymbol(next.rightField)) {
|
if (!isThresholdSymbol(next.rightField)) {
|
||||||
const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC'
|
const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC'
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import {
|
|||||||
THRESHOLD_HL_MID,
|
THRESHOLD_HL_MID,
|
||||||
THRESHOLD_HL_OVER,
|
THRESHOLD_HL_OVER,
|
||||||
THRESHOLD_HL_UNDER,
|
THRESHOLD_HL_UNDER,
|
||||||
|
defaultThresholdForRole,
|
||||||
isThresholdFieldOrSymbol,
|
isThresholdFieldOrSymbol,
|
||||||
isThresholdSymbol,
|
isThresholdSymbol,
|
||||||
resolveInheritedThresholdField,
|
resolveInheritedThresholdField,
|
||||||
@@ -846,11 +847,16 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D
|
|||||||
}
|
}
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
|
const isValidRight = (v: string|undefined) => {
|
||||||
|
if (!v) return false;
|
||||||
|
if (isThresholdOverridden(cond) && (v.startsWith('K_') || isThresholdSymbol(v))) return true;
|
||||||
|
return isValid(v);
|
||||||
|
};
|
||||||
const def = getDefaultConditionFields(cond.indicatorType, 'buy', DEF);
|
const def = getDefaultConditionFields(cond.indicatorType, 'buy', DEF);
|
||||||
|
|
||||||
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
|
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
|
||||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||||
if (!isValid(updated.rightField)) updated.rightField = def.r;
|
if (!isValidRight(updated.rightField)) updated.rightField = def.r;
|
||||||
if (['DIFF_GT','DIFF_LT'].includes(newCondType)) updated.compareValue = updated.compareValue ?? 0;
|
if (['DIFF_GT','DIFF_LT'].includes(newCondType)) updated.compareValue = updated.compareValue ?? 0;
|
||||||
} else if (['SLOPE_UP','SLOPE_DOWN'].includes(newCondType)) {
|
} else if (['SLOPE_UP','SLOPE_DOWN'].includes(newCondType)) {
|
||||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||||
@@ -1417,7 +1423,9 @@ export const CondEditor: React.FC<CondEditorProps> = ({
|
|||||||
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
|
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
|
||||||
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
|
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
|
||||||
?? parseThresholdField(normalized.rightField)
|
?? parseThresholdField(normalized.rightField)
|
||||||
?? 0}
|
?? defaultThresholdForRole(normalized.indicatorType, THRESHOLD_HL_MID, def.hlThresh)
|
||||||
|
?? defaultThresholdForRole(normalized.indicatorType, THRESHOLD_HL_OVER, def.hlThresh)
|
||||||
|
?? 50}
|
||||||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||||||
min={getThresholdBounds(normalized.indicatorType).min}
|
min={getThresholdBounds(normalized.indicatorType).min}
|
||||||
max={getThresholdBounds(normalized.indicatorType).max}
|
max={getThresholdBounds(normalized.indicatorType).max}
|
||||||
|
|||||||
@@ -15,16 +15,53 @@ export function isThresholdFieldOrSymbol(field?: string): boolean {
|
|||||||
|
|
||||||
type HlThresh = { over?: number; mid?: number; under?: number };
|
type HlThresh = { over?: number; mid?: number; under?: number };
|
||||||
|
|
||||||
|
/** Ta4j IndicatorHlineResolver.defaultForRole 와 동일 — hlThresh 미설정 시 폴백 */
|
||||||
|
export const DEFAULT_HL_THRESH: Record<string, HlThresh> = {
|
||||||
|
RSI: { over: 70, mid: 50, under: 30 },
|
||||||
|
STOCHASTIC: { over: 80, mid: 50, under: 20 },
|
||||||
|
CCI: { over: 100, mid: 0, under: -100 },
|
||||||
|
WILLIAMS_R: { over: -20, mid: -50, under: -80 },
|
||||||
|
BWI: { over: 80, mid: 50, under: 20 },
|
||||||
|
VR: { over: 200, mid: 100, under: 50 },
|
||||||
|
PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 },
|
||||||
|
NEW_PSYCHOLOGICAL: { over: 50, mid: 0, under: -50 },
|
||||||
|
INVEST_PSYCHOLOGICAL:{ over: 75, mid: 50, under: 25 },
|
||||||
|
MACD: { mid: 0 },
|
||||||
|
ADX: { over: 40, mid: 25, under: 20 },
|
||||||
|
DMI: { over: 30, mid: 20, under: 10 },
|
||||||
|
TRIX: { mid: 0 },
|
||||||
|
VOLUME_OSC: { mid: 0 },
|
||||||
|
DISPARITY: { mid: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergedHlThresh(
|
||||||
|
indicatorType: string,
|
||||||
|
hlThresh: Record<string, HlThresh>,
|
||||||
|
): HlThresh {
|
||||||
|
return { ...DEFAULT_HL_THRESH[indicatorType], ...hlThresh[indicatorType] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultThresholdForRole(
|
||||||
|
indicatorType: string,
|
||||||
|
role: ThresholdHlineRole,
|
||||||
|
hlThresh: Record<string, HlThresh> = {},
|
||||||
|
): number | null {
|
||||||
|
const th = mergedHlThresh(indicatorType, hlThresh);
|
||||||
|
if (role === THRESHOLD_HL_OVER) return th.over ?? null;
|
||||||
|
if (role === THRESHOLD_HL_MID) return th.mid ?? null;
|
||||||
|
if (role === THRESHOLD_HL_UNDER) return th.under ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveThresholdFromDef(
|
export function resolveThresholdFromDef(
|
||||||
field: string | undefined,
|
field: string | undefined,
|
||||||
indicatorType: string,
|
indicatorType: string,
|
||||||
hlThresh: Record<string, HlThresh>,
|
hlThresh: Record<string, HlThresh>,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!field) return null;
|
if (!field) return null;
|
||||||
const th = hlThresh[indicatorType] ?? {};
|
if (field === THRESHOLD_HL_OVER) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_OVER, hlThresh);
|
||||||
if (field === THRESHOLD_HL_OVER) return th.over ?? null;
|
if (field === THRESHOLD_HL_MID) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_MID, hlThresh);
|
||||||
if (field === THRESHOLD_HL_MID) return th.mid ?? null;
|
if (field === THRESHOLD_HL_UNDER) return defaultThresholdForRole(indicatorType, THRESHOLD_HL_UNDER, hlThresh);
|
||||||
if (field === THRESHOLD_HL_UNDER) return th.under ?? null;
|
|
||||||
if (field.startsWith('K_')) {
|
if (field.startsWith('K_')) {
|
||||||
const n = parseFloat(field.slice(2));
|
const n = parseFloat(field.slice(2));
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -41,7 +78,7 @@ export function inferThresholdSymbolFromLegacy(
|
|||||||
if (!field?.startsWith('K_')) return field;
|
if (!field?.startsWith('K_')) return field;
|
||||||
const val = parseFloat(field.slice(2));
|
const val = parseFloat(field.slice(2));
|
||||||
if (!Number.isFinite(val)) return field;
|
if (!Number.isFinite(val)) return field;
|
||||||
const th = hlThresh[indicatorType] ?? {};
|
const th = mergedHlThresh(indicatorType, hlThresh);
|
||||||
const eps = 0.0001;
|
const eps = 0.0001;
|
||||||
if (th.over != null && Math.abs(th.over - val) < eps) return THRESHOLD_HL_OVER;
|
if (th.over != null && Math.abs(th.over - val) < eps) return THRESHOLD_HL_OVER;
|
||||||
if (th.mid != null && Math.abs(th.mid - val) < eps) return THRESHOLD_HL_MID;
|
if (th.mid != null && Math.abs(th.mid - val) < eps) return THRESHOLD_HL_MID;
|
||||||
@@ -80,6 +117,21 @@ export function resolveInheritedThresholdField(
|
|||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** K_ 숫자값이 hline 역할과 일치하면 역할 심볼 반환 (직접입력 ↔ 중앙선 등가) */
|
||||||
|
export function inferThresholdRoleForValue(
|
||||||
|
value: number,
|
||||||
|
indicatorType: string,
|
||||||
|
hlThresh: Record<string, HlThresh> = {},
|
||||||
|
): ThresholdHlineRole | null {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
const th = mergedHlThresh(indicatorType, hlThresh);
|
||||||
|
const eps = 0.0001;
|
||||||
|
if (th.over != null && Math.abs(th.over - value) < eps) return THRESHOLD_HL_OVER;
|
||||||
|
if (th.mid != null && Math.abs(th.mid - value) < eps) return THRESHOLD_HL_MID;
|
||||||
|
if (th.under != null && Math.abs(th.under - value) < eps) return THRESHOLD_HL_UNDER;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 상속 모드 기본 rightField — 과열선 역할 */
|
/** 상속 모드 기본 rightField — 과열선 역할 */
|
||||||
export function defaultInheritedThresholdField(indicatorType: string): string {
|
export function defaultInheritedThresholdField(indicatorType: string): string {
|
||||||
switch (indicatorType) {
|
switch (indicatorType) {
|
||||||
|
|||||||
Reference in New Issue
Block a user