전략편집기 수정
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface CompositePeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -65,8 +66,37 @@ export function compositeValueField(ind: string, period: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** K_ 접두 임계값 필드 — 기간 파싱·복합지표 승격에서 제외 */
|
||||
export function isThresholdField(field?: string): boolean {
|
||||
return !!field && field.startsWith('K_');
|
||||
}
|
||||
|
||||
const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
|
||||
RSI: 'RSI_VALUE',
|
||||
CCI: 'CCI_VALUE',
|
||||
ADX: 'ADX_VALUE',
|
||||
WILLIAMS_R: 'WILLIAMS_R_VALUE',
|
||||
TRIX: 'TRIX_VALUE',
|
||||
VR: 'VR_VALUE',
|
||||
PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||
};
|
||||
|
||||
/** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */
|
||||
export function isCompositeValueField(ind: string, field?: string): boolean {
|
||||
if (!field || isThresholdField(field)) return false;
|
||||
if (ind === 'DISPARITY') {
|
||||
return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length
|
||||
&& /^\d+$/.test(field.slice('DISPARITY'.length));
|
||||
}
|
||||
const prefix = COMPOSITE_VALUE_PREFIX[ind];
|
||||
if (!prefix) return false;
|
||||
return field === prefix || field.startsWith(`${prefix}_`);
|
||||
}
|
||||
|
||||
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
|
||||
if (!field) return null;
|
||||
if (!field || isThresholdField(field)) return null;
|
||||
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
|
||||
const n = parseInt(field.slice('DISPARITY'.length), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
@@ -93,6 +123,8 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
...(rightP != null ? { rightPeriod: rightP } : null),
|
||||
};
|
||||
}
|
||||
const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
|
||||
const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
|
||||
return {
|
||||
...cond,
|
||||
composite: true,
|
||||
@@ -100,14 +132,30 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
rightPeriod: rightP,
|
||||
leftField: compositeValueField(cond.indicatorType, leftP),
|
||||
rightField: compositeValueField(cond.indicatorType, rightP),
|
||||
leftCandleType: leftCt,
|
||||
rightCandleType: rightCt,
|
||||
period: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
if (cond.composite) return syncCompositeFields(cond);
|
||||
const leftP = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
||||
if (cond.composite) {
|
||||
if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) {
|
||||
return {
|
||||
...cond,
|
||||
composite: false,
|
||||
leftPeriod: undefined,
|
||||
rightPeriod: undefined,
|
||||
};
|
||||
}
|
||||
return syncCompositeFields(cond);
|
||||
}
|
||||
const ind = cond.indicatorType;
|
||||
if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) {
|
||||
return cond;
|
||||
}
|
||||
const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
|
||||
if (leftP && rightP && leftP !== rightP) {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
@@ -131,6 +179,8 @@ export function makeCompositeCondition(
|
||||
composite: true,
|
||||
leftPeriod: short,
|
||||
rightPeriod: long,
|
||||
leftCandleType: DEFAULT_START_CANDLE,
|
||||
rightCandleType: DEFAULT_START_CANDLE,
|
||||
candleRange: 1,
|
||||
});
|
||||
}
|
||||
@@ -151,3 +201,8 @@ export function compositeElementLabel(ind: string, slot: 1 | 2): string {
|
||||
const name = item?.label ?? ind;
|
||||
return `${name} ${slot}`;
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상 라벨 — 예: CCI 1 라인(9일) */
|
||||
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
|
||||
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators';
|
||||
import {
|
||||
compositeFieldLabel,
|
||||
compositeValueField,
|
||||
getCompositeDefaultPeriods,
|
||||
isThresholdField,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
type CompositePeriodDef,
|
||||
} from './compositeIndicators';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface IndicatorPeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -41,7 +50,7 @@ export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorP
|
||||
}
|
||||
|
||||
function parseFieldPeriod(field?: string): number | null {
|
||||
if (!field) return null;
|
||||
if (!field || isThresholdField(field)) return null;
|
||||
const m = field.match(/_(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
@@ -72,6 +81,24 @@ export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriod
|
||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||
}
|
||||
|
||||
/** 조건대상 직접입력 — 기간(일) 또는 임계값 */
|
||||
export function resolveFieldCustomKind(
|
||||
indicatorType: string,
|
||||
field: string | undefined,
|
||||
): 'period' | 'threshold' | 'none' {
|
||||
if (!field) return 'none';
|
||||
if (isThresholdField(field)) return 'threshold';
|
||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||
if (prefix && (field === prefix || field.startsWith(`${prefix}_`))) return 'period';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
|
||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||
if (!prefix || !field) return false;
|
||||
return field.startsWith(`${prefix}_`) && field.length > prefix.length + 1;
|
||||
}
|
||||
|
||||
export function usesValuePeriodField(cond: ConditionDSL): boolean {
|
||||
if (cond.composite) return true;
|
||||
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||
@@ -178,6 +205,49 @@ export function getCompositePeriodPresetOptions(
|
||||
return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */
|
||||
export function getCompositeLeftCandleType(cond: ConditionDSL): string {
|
||||
return normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
|
||||
}
|
||||
|
||||
export function getCompositeRightCandleType(cond: ConditionDSL): string {
|
||||
return normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
|
||||
}
|
||||
|
||||
export function setCompositeLeftCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
composite: true,
|
||||
leftCandleType: normalizeStartCandleType(candleType),
|
||||
});
|
||||
}
|
||||
|
||||
export function setCompositeRightCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
composite: true,
|
||||
rightCandleType: normalizeStartCandleType(candleType),
|
||||
});
|
||||
}
|
||||
|
||||
export function getCompositeFieldOpts(
|
||||
indicatorType: string,
|
||||
slot: 1 | 2,
|
||||
def: IndicatorPeriodDef,
|
||||
cond: ConditionDSL,
|
||||
): { value: string; label: string }[] {
|
||||
const side = slot === 1 ? 'left' : 'right';
|
||||
const current = slot === 1
|
||||
? getConditionValuePeriod(cond, def)
|
||||
: getConditionRightPeriod(cond, def);
|
||||
const presets = getCompositePeriodPresetOptions(indicatorType, def, side);
|
||||
const periods = [...new Set([...presets, current])].sort((a, b) => a - b);
|
||||
return periods.map(p => ({
|
||||
value: compositeValueField(indicatorType, p),
|
||||
label: compositeFieldLabel(indicatorType, slot, p),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getThresholdPresetOptions(indicatorType: string): number[] {
|
||||
switch (indicatorType) {
|
||||
case 'RSI':
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* API·JSON·WebSocket에서 문자열로 올 수 있는 숫자를 안전하게 처리
|
||||
*/
|
||||
export function coerceFiniteNumber(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const trimmed = v.trim().replace(/,/g, '');
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** toFixed 크래시 방지 — 숫자가 아니면 '—' */
|
||||
export function safeToFixed(v: unknown, digits = 2, fallback = '—'): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return fallback;
|
||||
try {
|
||||
return n.toFixed(digits);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function safePercentFromRate(v: unknown, digits = 2): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return '—';
|
||||
const sign = n >= 0 ? '+' : '';
|
||||
return `${sign}${safeToFixed(n * 100, digits, '0.00')}%`;
|
||||
}
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
normalizeStartCombineOp,
|
||||
type EditorConditionState,
|
||||
} from './strategyConditionSerde';
|
||||
import {
|
||||
compositeDisplayName,
|
||||
normalizeCompositeCondition,
|
||||
} from './compositeIndicators';
|
||||
import { normalizeCompositeCondition } from './compositeIndicators';
|
||||
import {
|
||||
getFieldOpts,
|
||||
resolveFieldOptionValue,
|
||||
type DefType,
|
||||
} from './strategyEditorShared';
|
||||
import { parseThresholdField } from './conditionPeriods';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
parseThresholdField,
|
||||
} from './conditionPeriods';
|
||||
import { formatStrategyCandleLabel } from './strategyStartNodes';
|
||||
import { formatStartCandleLabel } from './strategyStartNodes';
|
||||
|
||||
export interface StrategyDescriptionInput {
|
||||
@@ -66,7 +69,13 @@ function fieldLabel(
|
||||
def: DefType,
|
||||
signalType: 'buy' | 'sell',
|
||||
cond?: ReturnType<typeof normalizeCompositeCondition>,
|
||||
slot?: 1 | 2,
|
||||
): string {
|
||||
if (cond?.composite && field && slot) {
|
||||
const compositeOpts = getCompositeFieldOpts(indicatorType, slot, def, cond);
|
||||
const hit = compositeOpts.find(o => o.value === field);
|
||||
if (hit) return hit.label;
|
||||
}
|
||||
const opts = getFieldOpts(indicatorType, signalType, def, cond);
|
||||
const resolved = resolveFieldOptionValue(indicatorType, field);
|
||||
const hit = opts.find(o => o.value === resolved);
|
||||
@@ -128,21 +137,19 @@ function describeCondition(
|
||||
const ct = cond.conditionType;
|
||||
|
||||
if (cond.composite && cond.leftPeriod && cond.rightPeriod) {
|
||||
const name = compositeDisplayName(ind).split(' + ')[0] ?? ind;
|
||||
const short = `${name} ${cond.leftPeriod}기간`;
|
||||
const long = `${name} ${cond.rightPeriod}기간`;
|
||||
switch (ct) {
|
||||
case 'GT': return `${short} 값이 ${long} 값보다 큰 경우`;
|
||||
case 'LT': return `${short} 값이 ${long} 값보다 작은 경우`;
|
||||
case 'GTE': return `${short} 값이 ${long} 값 이상인 경우`;
|
||||
case 'LTE': return `${short} 값이 ${long} 값 이하인 경우`;
|
||||
case 'CROSS_UP': return `${short} 값이 ${long} 값을 상향 돌파하는 경우`;
|
||||
case 'CROSS_DOWN': return `${short} 값이 ${long} 값을 하향 돌파하는 경우`;
|
||||
default: {
|
||||
const label = CONDITION_LABEL[ct] ?? ct;
|
||||
return `${short}과(와) ${long}을(를) 비교할 때 「${label}」 조건이 성립하는 경우`;
|
||||
}
|
||||
}
|
||||
const left = fieldLabel(ind, cond.leftField, def, signalType, cond, 1);
|
||||
const right = fieldLabel(ind, cond.rightField, def, signalType, cond, 2);
|
||||
const lCt = formatStrategyCandleLabel(getCompositeLeftCandleType(cond));
|
||||
const rCt = formatStrategyCandleLabel(getCompositeRightCandleType(cond));
|
||||
const tfNote = lCt !== rCt
|
||||
? ` (${lCt} 봉의 ${left}과(와) ${rCt} 봉의 ${right} 비교)`
|
||||
: ` (${lCt} 봉)`;
|
||||
const base = describeConditionType(ct, left, right, {
|
||||
compareValue: cond.compareValue,
|
||||
slopePeriod: cond.slopePeriod,
|
||||
holdDays: cond.holdDays,
|
||||
});
|
||||
return base + tfNote;
|
||||
}
|
||||
|
||||
const left = fieldLabel(ind, cond.leftField, def, signalType, cond);
|
||||
|
||||
@@ -6,21 +6,36 @@ import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTy
|
||||
import {
|
||||
compositeDisplayName,
|
||||
compositeElementLabel,
|
||||
compositeFieldLabel,
|
||||
makeCompositeCondition,
|
||||
normalizeCompositeCondition,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
} from '../utils/compositeIndicators';
|
||||
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
|
||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
getCompositePeriodPresetOptions,
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getConditionValuePeriod,
|
||||
getDefaultIndicatorPeriod,
|
||||
getPeriodPresetOptions,
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
initConditionPeriods,
|
||||
isValuePeriodFieldStored,
|
||||
parseThresholdField,
|
||||
resolveFieldCustomKind,
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
setCompositeLeftCandleType,
|
||||
setCompositeRightCandleType,
|
||||
setConditionThreshold,
|
||||
setConditionRightPeriod,
|
||||
setConditionValuePeriod,
|
||||
} from '../utils/conditionPeriods';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
@@ -305,7 +320,7 @@ export const getFieldOpts = (
|
||||
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
||||
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||||
return condOpts([
|
||||
{ value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` },
|
||||
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -325,7 +340,7 @@ export const getFieldOpts = (
|
||||
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
||||
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
||||
return condOpts([
|
||||
{ value:'CCI_VALUE', label:`CCI 값(${cciP}일)` },
|
||||
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -334,7 +349,7 @@ export const getFieldOpts = (
|
||||
case 'ADX': {
|
||||
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
|
||||
return condOpts([
|
||||
{ value:'ADX_VALUE', label:`ADX 값(${DEF.adxPeriod}일)` },
|
||||
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -343,7 +358,7 @@ export const getFieldOpts = (
|
||||
case 'TRIX': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'TRIX_VALUE', label:`TRIX 값(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
]);
|
||||
@@ -387,7 +402,7 @@ export const getFieldOpts = (
|
||||
case 'NEW_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'PSY_VALUE', label:`심리도 값(${DEF.newPsy}일)` },
|
||||
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -396,7 +411,7 @@ export const getFieldOpts = (
|
||||
case 'INVEST_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 값(${DEF.investPsy}일)` },
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -405,7 +420,7 @@ export const getFieldOpts = (
|
||||
case 'WILLIAMS_R': {
|
||||
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
|
||||
return condOpts([
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 값(${DEF.williamsR}일)` },
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -414,7 +429,7 @@ export const getFieldOpts = (
|
||||
case 'BWI': {
|
||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
||||
return condOpts([
|
||||
{ value:'BWI_VALUE', label:`BWI 값(${DEF.bwiPeriod}일)` },
|
||||
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -423,7 +438,7 @@ export const getFieldOpts = (
|
||||
case 'VR': {
|
||||
const { over, mid, under } = th({ over:200, mid:100, under:50 });
|
||||
return condOpts([
|
||||
{ value:'VR_VALUE', label:`VR 값(${DEF.vrPeriod}일)` },
|
||||
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -586,8 +601,16 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||
const C = condLabel(c.conditionType);
|
||||
if (c.composite && c.leftPeriod && c.rightPeriod) {
|
||||
const name = compositeDisplayName(c.indicatorType).split(' + ')[0];
|
||||
return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`;
|
||||
const leftOpts = getCompositeFieldOpts(c.indicatorType, 1, DEF, c);
|
||||
const rightOpts = getCompositeFieldOpts(c.indicatorType, 2, DEF, c);
|
||||
const L = leftOpts.find(o => o.value === c.leftField)?.label
|
||||
?? compositeFieldLabel(c.indicatorType, 1, c.leftPeriod);
|
||||
const R = rightOpts.find(o => o.value === c.rightField)?.label
|
||||
?? compositeFieldLabel(c.indicatorType, 2, c.rightPeriod);
|
||||
const lCt = getCompositeLeftCandleType(c);
|
||||
const rCt = getCompositeRightCandleType(c);
|
||||
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
|
||||
return `${c.indicatorType} - ${L} ${C} ${R}${tf}`;
|
||||
}
|
||||
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||||
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
||||
@@ -734,11 +757,59 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
};
|
||||
|
||||
if (normalized.composite) {
|
||||
const leftOpts = getCompositeFieldOpts(normalized.indicatorType, 1, def, normalized);
|
||||
const rightOpts = getCompositeFieldOpts(normalized.indicatorType, 2, def, normalized);
|
||||
const leftField = normalized.leftField ?? leftOpts[0]?.value ?? 'NONE';
|
||||
const rightField = normalized.rightField ?? rightOpts[0]?.value ?? 'NONE';
|
||||
const leftIsPreset = leftOpts.some(o => o.value === leftField);
|
||||
const rightIsPreset = rightOpts.some(o => o.value === rightField);
|
||||
const leftPeriod = getConditionValuePeriod(normalized, def);
|
||||
const rightPeriod = getConditionRightPeriod(normalized, def);
|
||||
const periodPresetsLeft = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left');
|
||||
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
|
||||
|
||||
const handleCompositeField = (side: 'left' | 'right', field: string) => {
|
||||
const p = parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
if (p == null) return;
|
||||
if (side === 'left') {
|
||||
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
|
||||
} else {
|
||||
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field }));
|
||||
}
|
||||
};
|
||||
|
||||
const leftCt = getCompositeLeftCandleType(normalized);
|
||||
const rightCt = getCompositeRightCandleType(normalized);
|
||||
|
||||
return (
|
||||
<div className="sp-cond-editor sp-cond-editor--composite">
|
||||
<div className="sp-cond-composite-badge">복합지표 · {compositeDisplayName(normalized.indicatorType)}</div>
|
||||
<div className="sp-cond-row-composite-tf">
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 시간봉</label>
|
||||
<select
|
||||
className="sp-cond-sel"
|
||||
value={leftCt}
|
||||
onChange={e => onChange(setCompositeLeftCandleType(normalized, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 시간봉</label>
|
||||
<select
|
||||
className="sp-cond-sel"
|
||||
value={rightCt}
|
||||
onChange={e => onChange(setCompositeRightCandleType(normalized, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-cond-row4">
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">캔들 범위</label>
|
||||
@@ -752,23 +823,35 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
</select>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 기준값 (기간)</label>
|
||||
<ComboNumberInput
|
||||
value={leftPeriod}
|
||||
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left')}
|
||||
<label className="sp-cond-lbl">조건대상1</label>
|
||||
<ComboFieldSelect
|
||||
options={leftOpts}
|
||||
fieldValue={leftField}
|
||||
isPresetField={leftIsPreset}
|
||||
customKind="period"
|
||||
customNumber={leftPeriod}
|
||||
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 1, leftPeriod)}
|
||||
numberPresets={periodPresetsLeft}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
onFieldChange={f => handleCompositeField('left', f)}
|
||||
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 기준값 (기간)</label>
|
||||
<ComboNumberInput
|
||||
value={rightPeriod}
|
||||
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right')}
|
||||
<label className="sp-cond-lbl">조건대상2</label>
|
||||
<ComboFieldSelect
|
||||
options={rightOpts}
|
||||
fieldValue={rightField}
|
||||
isPresetField={rightIsPreset}
|
||||
customKind="period"
|
||||
customNumber={rightPeriod}
|
||||
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 2, rightPeriod)}
|
||||
numberPresets={periodPresetsRight}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionRightPeriod(normalized, p))}
|
||||
onFieldChange={f => handleCompositeField('right', f)}
|
||||
onCustomNumberChange={p => onChange(setConditionRightPeriod(normalized, p))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
@@ -803,20 +886,57 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
{/* 조건대상1 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상1</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={isHoldType ? 'NONE' : getLeftValue()}
|
||||
onChange={e => onChange({ ...cond, leftField: e.target.value })}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ComboFieldSelect
|
||||
options={fieldOpts}
|
||||
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
|
||||
isPresetField={!isHoldType && (
|
||||
fieldOpts.some(o => o.value === normalized.leftField)
|
||||
|| (fieldOpts.some(o => o.value === getLeftValue())
|
||||
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|
||||
)}
|
||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
|
||||
customNumber={getConditionValuePeriod(normalized, def)}
|
||||
numberPresets={getPeriodPresetOptions(normalized.indicatorType, def)}
|
||||
min={1}
|
||||
max={500}
|
||||
disabled={isHoldType}
|
||||
onFieldChange={v => {
|
||||
const bases: Record<string, string> = {
|
||||
RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE',
|
||||
WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE', VR: 'VR_VALUE',
|
||||
PSYCHOLOGICAL: 'PSY_VALUE', NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||
};
|
||||
const base = bases[normalized.indicatorType];
|
||||
if (base && v === base) {
|
||||
const p = getConditionValuePeriod(normalized, def);
|
||||
onChange({ ...normalized, leftField: `${base}_${p}`, period: p });
|
||||
} else {
|
||||
onChange({ ...normalized, leftField: v });
|
||||
}
|
||||
}}
|
||||
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
/>
|
||||
</div>
|
||||
{/* 조건대상2 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상2</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={rightValue}
|
||||
onChange={e => handleRight(e.target.value)}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ComboFieldSelect
|
||||
options={fieldOpts}
|
||||
fieldValue={rightValue}
|
||||
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
|
||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
||||
customNumber={getConditionThreshold(normalized, 'right')
|
||||
?? parseThresholdField(normalized.rightField)
|
||||
?? 0}
|
||||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||||
min={getThresholdBounds(normalized.indicatorType).min}
|
||||
max={getThresholdBounds(normalized.indicatorType).max}
|
||||
allowDecimal={normalized.indicatorType === 'CCI'}
|
||||
disabled={isSlopeType || isHoldType}
|
||||
onFieldChange={handleRight}
|
||||
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))}
|
||||
/>
|
||||
</div>
|
||||
{/* 조건 */}
|
||||
<div className="sp-cond-field">
|
||||
@@ -858,7 +978,38 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
};
|
||||
|
||||
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
||||
export type MakeNodeOptions = { composite?: boolean };
|
||||
export type MakeNodeOptions = {
|
||||
composite?: boolean;
|
||||
/** 단일 보조지표 기간 오버라이드 */
|
||||
period?: number;
|
||||
/** 복합지표 단기·장기 기간 오버라이드 */
|
||||
leftPeriod?: number;
|
||||
rightPeriod?: number;
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
};
|
||||
|
||||
/** 팔레트 드래그 payload → makeNode 옵션 */
|
||||
export function makeNodeOptionsFromPalette(data: {
|
||||
composite?: boolean;
|
||||
period?: number;
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
}): MakeNodeOptions | undefined {
|
||||
if (data.composite) {
|
||||
return {
|
||||
composite: true,
|
||||
leftPeriod: data.shortPeriod,
|
||||
rightPeriod: data.longPeriod,
|
||||
leftCandleType: data.leftCandleType,
|
||||
rightCandleType: data.rightCandleType,
|
||||
};
|
||||
}
|
||||
if (data.period != null) return { period: data.period };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const makeNode = (
|
||||
type: string,
|
||||
@@ -869,20 +1020,28 @@ export const makeNode = (
|
||||
): LogicNode => {
|
||||
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
||||
if (options?.composite) {
|
||||
return {
|
||||
id: genId(),
|
||||
type: 'CONDITION',
|
||||
condition: makeCompositeCondition(value, signalType, DEF),
|
||||
};
|
||||
let condition = makeCompositeCondition(value, signalType, DEF);
|
||||
if (options.leftPeriod != null || options.rightPeriod != null
|
||||
|| options.leftCandleType != null || options.rightCandleType != null) {
|
||||
condition = syncCompositeFields({
|
||||
...condition,
|
||||
leftPeriod: options.leftPeriod ?? condition.leftPeriod,
|
||||
rightPeriod: options.rightPeriod ?? condition.rightPeriod,
|
||||
leftCandleType: options.leftCandleType ?? condition.leftCandleType,
|
||||
rightCandleType: options.rightCandleType ?? condition.rightCandleType,
|
||||
});
|
||||
}
|
||||
return { id: genId(), type: 'CONDITION', condition };
|
||||
}
|
||||
const def = getDefaultFields(value, signalType, DEF);
|
||||
const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF);
|
||||
const baseCondition: ConditionDSL = {
|
||||
indicatorType: value,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
leftField: def.l,
|
||||
rightField: def.r,
|
||||
candleRange: 1,
|
||||
period: getDefaultIndicatorPeriod(value, DEF),
|
||||
period,
|
||||
};
|
||||
return {
|
||||
id: genId(), type: 'CONDITION',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
|
||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||
|
||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||
|
||||
export interface PaletteItem {
|
||||
id: string;
|
||||
kind: PaletteItemKind;
|
||||
/** 지표 타입 (RSI, CCI, …) */
|
||||
value: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
/** 단일 보조지표 기간 */
|
||||
period?: number;
|
||||
/** 복합지표 단기·장기 기간 */
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_AUX = 'se-palette-auxiliary-v1';
|
||||
const STORAGE_COMP = 'se-palette-composite-v1';
|
||||
|
||||
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
|
||||
{ value: 'STOCHASTIC', label: 'Stochastic', desc: '스토캐스틱', builtIn: true },
|
||||
{ value: 'CCI', label: 'CCI', desc: '상품채널지수', builtIn: true },
|
||||
{ value: 'ADX', label: 'ADX', desc: '평균방향지수', builtIn: true },
|
||||
{ value: 'DMI', label: 'DMI', desc: '방향성 지표', builtIn: true },
|
||||
{ value: 'OBV', label: 'OBV', desc: '거래량 균형', builtIn: true },
|
||||
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: '윌리엄스 %R', builtIn: true },
|
||||
{ value: 'TRIX', label: 'TRIX', desc: '삼중지수이동평균', builtIn: true },
|
||||
{ value: 'VOLUME_OSC', label: 'Volume OSC', desc: '거래량 오실레이터', builtIn: true },
|
||||
{ value: 'VR', label: 'VR', desc: '거래량 비율', builtIn: true },
|
||||
{ value: 'DISPARITY', label: '이격도', desc: 'Disparity', builtIn: true },
|
||||
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true },
|
||||
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true },
|
||||
{ value: 'VOLUME', label: '거래량', desc: 'Volume', builtIn: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
|
||||
COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
desc: i.desc,
|
||||
builtIn: true,
|
||||
}));
|
||||
|
||||
export const AUXILIARY_TYPE_OPTIONS = DEFAULT_AUXILIARY_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
}));
|
||||
|
||||
export const COMPOSITE_TYPE_OPTIONS = DEFAULT_COMPOSITE_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
}));
|
||||
|
||||
function builtinId(kind: PaletteItemKind, value: string): string {
|
||||
return `builtin-${kind}-${value}`;
|
||||
}
|
||||
|
||||
function seedItems(
|
||||
kind: PaletteItemKind,
|
||||
defaults: Omit<PaletteItem, 'id' | 'kind'>[],
|
||||
): PaletteItem[] {
|
||||
return defaults.map(d => ({
|
||||
...d,
|
||||
id: builtinId(kind, d.value),
|
||||
kind,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseStored(raw: string | null): PaletteItem[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const arr = JSON.parse(raw) as PaletteItem[];
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.filter(
|
||||
i => i && typeof i.id === 'string' && typeof i.value === 'string' && i.label,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return stored.map(i => ({ ...i, kind }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return seedItems(kind, defaults);
|
||||
}
|
||||
|
||||
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
const items = seedItems(kind, defaults);
|
||||
savePaletteItems(kind, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function createPaletteItemId(): string {
|
||||
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
@@ -39,3 +39,25 @@ export function normalizeStartCandleType(value: string | undefined | null): stri
|
||||
export function formatStartCandleLabel(candleType: string): string {
|
||||
return normalizeStartCandleType(candleType);
|
||||
}
|
||||
|
||||
const CANDLE_TYPE_LABELS: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'10m': '10분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1d': '일봉',
|
||||
};
|
||||
|
||||
export function formatStrategyCandleLabel(candleType: string): string {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
return CANDLE_TYPE_LABELS[ct] ?? ct;
|
||||
}
|
||||
|
||||
export const STRATEGY_CANDLE_TYPE_OPTIONS = STRATEGY_CANDLE_TYPES.map(ct => ({
|
||||
value: ct,
|
||||
label: CANDLE_TYPE_LABELS[ct] ?? ct,
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface ConditionDSL {
|
||||
composite?: boolean;
|
||||
leftPeriod?: number;
|
||||
rightPeriod?: number;
|
||||
/** 복합지표 — 요소1·요소2 각각의 조건 판별 시간봉 (1m, 5m, …) */
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
leftField?: string;
|
||||
rightField?: string;
|
||||
compareValue?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { coerceFiniteNumber } from './safeFormat';
|
||||
import {
|
||||
coerceFiniteNumber,
|
||||
formatIndicatorValue,
|
||||
isConditionMet,
|
||||
type VirtualConditionRow,
|
||||
@@ -149,10 +149,21 @@ export function formatStrategyThreshold(
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeConditionRow(
|
||||
row: VirtualConditionRow & { currentValue?: unknown },
|
||||
): VirtualConditionRow & { currentValue: number | null } {
|
||||
return {
|
||||
...row,
|
||||
targetValue: coerceFiniteNumber(row.targetValue),
|
||||
currentValue: coerceFiniteNumber(row.currentValue ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConditionMetrics(
|
||||
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
|
||||
): ConditionMetric[] {
|
||||
return rows.map(row => {
|
||||
return rows.map(raw => {
|
||||
const row = normalizeConditionRow(raw);
|
||||
const matchRate = computeConditionMatchRate(row);
|
||||
return {
|
||||
row,
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { LogicNode } from './strategyTypes';
|
||||
import { CONDITION_LABEL } from './strategyTypes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
|
||||
import { coerceFiniteNumber, safeToFixed } from './safeFormat';
|
||||
|
||||
export { coerceFiniteNumber } from './safeFormat';
|
||||
|
||||
export interface VirtualConditionRow {
|
||||
id: string;
|
||||
@@ -71,14 +74,6 @@ function walk(
|
||||
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
|
||||
}
|
||||
|
||||
/** API·DSL에서 문자열로 올 수 있는 값을 안전하게 숫자로 변환 */
|
||||
export function coerceFiniteNumber(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
|
||||
if (!strategy) return [];
|
||||
const out: VirtualConditionRow[] = [];
|
||||
@@ -112,5 +107,5 @@ export function formatIndicatorValue(v: number | null | unknown): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return '—';
|
||||
if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return n.toFixed(2);
|
||||
return safeToFixed(n, 2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user