전략편집기 수정

This commit is contained in:
Macbook
2026-05-25 17:56:25 +09:00
parent aac6454724
commit 02d14e4b2b
39 changed files with 1974 additions and 288 deletions
+59 -4
View File
@@ -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}일)`;
}
+72 -2
View File
@@ -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':
+33
View File
@@ -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);
+198 -39
View File
@@ -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)}`;
}
+22
View File
@@ -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,
}));
+3
View File
@@ -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;
+13 -2
View File
@@ -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);
}