diff --git a/frontend/src/App.css b/frontend/src/App.css index 1656a53..62c98da 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -7643,6 +7643,16 @@ html.desktop-client .tmb-logo-version { color: var(--text2); line-height: 1.35; } +.se-cond-preset-section { + margin: 10px 0 4px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #a78bfa; +} +.se-cond-preset-section:first-of-type { + margin-top: 0; +} .se-cond-preset-empty { margin: 0; font-size: 0.68rem; diff --git a/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx b/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx index 168fd1d..3179618 100644 --- a/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx +++ b/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx @@ -65,6 +65,21 @@ export default function ConditionPresetGraph({ type, size = 48 }: Props) { )} + {(type === 'path_first_cross' || type === 'path_pullback_recross' || type === 'path_deep_recovery') && ( + <> + + + + )} + {type === 'path_first_cross' && ( + + )} + {type === 'path_pullback_recross' && ( + + )} + {type === 'path_deep_recovery' && ( + + )} ); } diff --git a/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx b/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx index 3f9832e..a5c5b38 100644 --- a/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx +++ b/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx @@ -1,38 +1,84 @@ import React, { useEffect, useRef } from 'react'; -import type { ConditionDSL } from '../../utils/strategyTypes'; +import type { ConditionDSL, LogicNode } from '../../utils/strategyTypes'; import type { DefType } from '../../utils/strategyEditorShared'; import { applyConditionPreset, + applyCompoundConditionPreset, getConditionPresetsForIndicator, type ConditionPreset, } from '../../utils/conditionPresets'; +import { isCompoundConditionPreset } from '../../utils/conditionPathPresets'; import ConditionPresetGraph from './ConditionPresetGraph'; interface Props { condition: ConditionDSL; def: DefType; signalType: 'buy' | 'sell'; + nodeId: string; onChange: (next: ConditionDSL) => void; + onReplaceNode: (node: LogicNode) => void; onClose: () => void; popoverClassName?: string; } +function PresetSection({ + title, + presets, + onPick, +}: { + title: string; + presets: ConditionPreset[]; + onPick: (preset: ConditionPreset) => void; +}) { + if (presets.length === 0) return null; + return ( + <> +

{title}

+
    + {presets.map(preset => ( +
  • + +
  • + ))} +
+ + ); +} + export default function ConditionPresetPicker({ condition, def, signalType, + nodeId, onChange, + onReplaceNode, onClose, popoverClassName = 'sp-node-preset-pop', }: Props) { const popRef = useRef(null); const presets = getConditionPresetsForIndicator(condition.indicatorType, def, signalType); - const sorted = [...presets].sort((a, b) => { - const aMatch = a.tags?.includes(signalType) ? 0 : 1; - const bMatch = b.tags?.includes(signalType) ? 0 : 1; - return aMatch - bMatch; - }); + const sortBySignal = (list: ConditionPreset[]) => + [...list].sort((a, b) => { + const aMatch = a.tags?.includes(signalType) ? 0 : 1; + const bMatch = b.tags?.includes(signalType) ? 0 : 1; + return aMatch - bMatch; + }); + + const basicPresets = sortBySignal(presets.filter(p => p.category !== 'path')); + const pathPresets = sortBySignal(presets.filter(p => p.category === 'path')); useEffect(() => { const onDoc = (e: MouseEvent) => { @@ -48,11 +94,15 @@ export default function ConditionPresetPicker({ }, [onClose]); const handlePick = (preset: ConditionPreset) => { - onChange(applyConditionPreset(condition, preset, def)); + if (isCompoundConditionPreset(preset)) { + onReplaceNode(applyCompoundConditionPreset(nodeId, condition, preset, def)); + } else { + onChange(applyConditionPreset(condition, preset, def)); + } onClose(); }; - if (sorted.length === 0) { + if (presets.length === 0) { return (
@@ -71,25 +121,8 @@ export default function ConditionPresetPicker({

그래프 패턴을 선택하면 캔들 범위·조건대상·조건이 자동 설정됩니다.

-
    - {sorted.map(preset => ( -
  • - -
  • - ))} -
+ +
); } diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index 629c71f..aa958c0 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -218,7 +218,9 @@ const TreeNodeComp: React.FC = ({ condition={node.condition} def={def} signalType={signalType} + nodeId={node.id} onChange={handleCondChange} + onReplaceNode={onUpdate} onClose={() => setPresetOpen(false)} popoverClassName="sp-node-preset-pop" /> diff --git a/frontend/src/utils/conditionPathPresets.ts b/frontend/src/utils/conditionPathPresets.ts new file mode 100644 index 0000000..25c7fcd --- /dev/null +++ b/frontend/src/utils/conditionPathPresets.ts @@ -0,0 +1,182 @@ +/** + * 상향 돌파 경로 판별 — 복합(AND) 조건 프리셋 + * @see 상향돌파 케이스별 판별.md + */ +import type { ConditionDSL } from './strategyTypes'; +import type { DefType } from './strategyEditorShared'; +import { getFieldOpts } from './strategyEditorShared'; +import { + THRESHOLD_HL_MID, + THRESHOLD_HL_OVER, + THRESHOLD_HL_UNDER, +} from './thresholdSymbols'; +import { thresholdField } from './conditionPeriods'; +import type { ConditionPreset, ConditionPresetGraph } from './conditionPresets'; + +const DEFAULT_PATH_LOOKBACK = 30; + +/** 상향 돌파 경로 판별 지원 지표 */ +const PATH_ELIGIBLE = new Set([ + 'RSI', 'CCI', 'STOCHASTIC', 'WILLIAMS_R', + 'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL', + 'VR', 'BWI', 'TRIX', +]); + +type PathLineConfig = { + valueField: string; + targetField: string; + floorField: string; +}; + +function pathLines(indicatorType: string): PathLineConfig | null { + switch (indicatorType) { + case 'RSI': + return { valueField: 'RSI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'CCI': + return { valueField: 'CCI_VALUE', targetField: thresholdField(50), floorField: THRESHOLD_HL_MID }; + case 'STOCHASTIC': + return { valueField: 'STOCH_K', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'WILLIAMS_R': + return { valueField: 'WILLIAMS_R_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'PSYCHOLOGICAL': + case 'INVEST_PSYCHOLOGICAL': + return { valueField: 'PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'NEW_PSYCHOLOGICAL': + return { valueField: 'NEW_PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'VR': + return { valueField: 'VR_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'BWI': + return { valueField: 'BWI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + case 'TRIX': + return { valueField: 'TRIX_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; + default: + return null; + } +} + +function fieldLabel( + indicatorType: string, + field: string, + def: DefType, + signalType: 'buy' | 'sell', + mock: ConditionDSL, +): string { + return getFieldOpts(indicatorType, signalType, def, mock).find(o => o.value === field)?.label ?? field; +} + +function withLookback(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { + return { ...patch, lookbackPeriod: n }; +} + +function withExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { + return { ...patch, candleRangeMode: 'EXISTS_IN', candleRange: n }; +} + +function withNotExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { + return { ...patch, candleRangeMode: 'NOT_EXISTS_IN', candleRange: n }; +} + +/** ① 0(바닥선) 위에서 목표선 첫 상향 돌파 */ +function buildPathFirstCross(cfg: PathLineConfig): Partial[] { + const { valueField, targetField, floorField } = cfg; + return [ + { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, + withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }), + withNotExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }), + ]; +} + +/** ② 목표선 위→아래 조정(바닥선 유지) 후 목표선 재상향 돌파 */ +function buildPathPullbackRecross(cfg: PathLineConfig): Partial[] { + const { valueField, targetField, floorField } = cfg; + return [ + { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, + withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }), + withExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }), + withExistsIn({ conditionType: 'LT', leftField: valueField, rightField: targetField }), + ]; +} + +/** ③ 바닥선 이탈(침체) 후 변곡·목표선 상향 돌파 */ +function buildPathDeepRecovery(cfg: PathLineConfig): Partial[] { + const { valueField, targetField, floorField } = cfg; + return [ + { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, + withLookback({ conditionType: 'LOWEST_LTE', leftField: valueField, rightField: floorField }), + ]; +} + +function enrichPathPreset( + preset: ConditionPreset, + indicatorType: string, + def: DefType, + signalType: 'buy' | 'sell', + cfg: PathLineConfig, +): ConditionPreset { + const mockCross: ConditionDSL = { + indicatorType, + conditionType: 'CROSS_UP', + leftField: cfg.valueField, + rightField: cfg.targetField, + }; + const left = fieldLabel(indicatorType, cfg.valueField, def, signalType, mockCross); + const target = fieldLabel(indicatorType, cfg.targetField, def, signalType, mockCross); + const floor = fieldLabel(indicatorType, cfg.floorField, def, signalType, { + ...mockCross, + conditionType: 'LOWEST_GTE', + rightField: cfg.floorField, + }); + const n = DEFAULT_PATH_LOOKBACK; + + const descById: Record = { + path_first_cross: { + label: `① ${target} 첫 상향 돌파 (${floor} 이상 유지)`, + description: `${left}이(가) 최근 ${n}봉 동안 ${floor} 아래로 내려간 적 없고, ${target} 위 체류 이력도 없을 때 ${target}을(를) 처음 상향 돌파 (AND ${preset.compound?.length ?? 3}조건)`, + }, + path_pullback_recross: { + label: `② ${target} 되돌림 후 재상향 돌파`, + description: `${left}이(가) ${floor} 위를 유지한 채 ${target} 위·아래를 오간 뒤, 다시 ${target}을(를) 상향 돌파 (AND ${preset.compound?.length ?? 4}조건)`, + }, + path_deep_recovery: { + label: `③ ${floor} 침체 후 ${target} 상향 돌파`, + description: `최근 ${n}봉 내 ${left}이(가) ${floor} 이하(침체) 구간을 거친 뒤, ${target}을(를) 상향 돌파하는 V자 반등 (AND ${preset.compound?.length ?? 2}조건)`, + }, + }; + + const extra = descById[preset.id]; + if (!extra) return preset; + return { ...preset, label: extra.label, description: extra.description }; +} + +export function getPathBreakoutPresets( + indicatorType: string, + def: DefType, + signalType: 'buy' | 'sell', +): ConditionPreset[] { + if (!PATH_ELIGIBLE.has(indicatorType)) return []; + const cfg = pathLines(indicatorType); + if (!cfg) return []; + + const base = (id: string, graph: ConditionPresetGraph, compound: Partial[]): ConditionPreset => ({ + id, + label: id, + description: '', + graph, + patch: {}, + category: 'path', + compound, + tags: ['buy'], + }); + + const presets: ConditionPreset[] = [ + base('path_first_cross', 'path_first_cross', buildPathFirstCross(cfg)), + base('path_pullback_recross', 'path_pullback_recross', buildPathPullbackRecross(cfg)), + base('path_deep_recovery', 'path_deep_recovery', buildPathDeepRecovery(cfg)), + ]; + + return presets.map(p => enrichPathPreset(p, indicatorType, def, signalType, cfg)); +} + +export function isCompoundConditionPreset(preset: ConditionPreset): boolean { + return !!preset.compound && preset.compound.length > 0; +} diff --git a/frontend/src/utils/conditionPresetLabels.ts b/frontend/src/utils/conditionPresetLabels.ts new file mode 100644 index 0000000..fe1f311 --- /dev/null +++ b/frontend/src/utils/conditionPresetLabels.ts @@ -0,0 +1,160 @@ +/** + * 조건 프리셋 — 지표·기준선 값 반영 라벨/설명 생성 + */ +import type { ConditionDSL } from './strategyTypes'; +import { CONDITION_LABEL } from './strategyTypes'; +import type { DefType } from './strategyEditorShared'; +import { getFieldOpts } from './strategyEditorShared'; +import type { ConditionPresetGraph } from './conditionPresets'; + +type PresetLike = { + id: string; + label: string; + description: string; + graph: ConditionPresetGraph; + patch: Partial; + tags?: ('buy' | 'sell')[]; +}; + +function resolveFieldLabel( + indicatorType: string, + field: string | undefined, + def: DefType, + signalType: 'buy' | 'sell', + mockCond: ConditionDSL, +): string { + if (!field || field === 'NONE') return ''; + const opts = getFieldOpts(indicatorType, signalType, def, mockCond); + return opts.find(o => o.value === field)?.label ?? field; +} + +function condVerb(condType: string): string { + switch (condType) { + case 'CROSS_UP': return '상향 돌파'; + case 'CROSS_DOWN': return '하향 돌파'; + case 'GT': return '초과'; + case 'LT': return '미만'; + case 'GTE': return '이상'; + case 'LTE': return '이하'; + case 'EQ': return '같음'; + case 'NEQ': return '다름'; + case 'SLOPE_UP': return '상승 기울기'; + case 'SLOPE_DOWN': return '하락 기울기'; + default: return CONDITION_LABEL[condType] ?? condType; + } +} + +function buildDescription( + left: string, + right: string, + condType: string, + slopePeriod?: number, +): string { + const sp = slopePeriod ?? 3; + switch (condType) { + case 'CROSS_UP': + if (left && right) { + return `${left}이(가) ${right}을(를) 아래에서 위로 돌파하는 순간 (직전 봉 ≤ 기준, 현재 봉 > 기준)`; + } + return '기준선을 아래에서 위로 돌파하는 순간'; + case 'CROSS_DOWN': + if (left && right) { + return `${left}이(가) ${right}을(를) 위에서 아래로 이탈하는 순간 (직전 봉 ≥ 기준, 현재 봉 < 기준)`; + } + return '기준선을 위에서 아래로 이탈하는 순간'; + case 'GT': + return left && right + ? `${left}이(가) ${right}보다 큰 상태` + : '비교 대상보다 큰 상태'; + case 'LT': + return left && right + ? `${left}이(가) ${right}보다 작은 상태` + : '비교 대상보다 작은 상태'; + case 'GTE': + return left && right + ? `${left}이(가) ${right} 이상인 상태` + : '기준선 이상 유지'; + case 'LTE': + return left && right + ? `${left}이(가) ${right} 이하인 상태` + : '기준선 이하 유지'; + case 'SLOPE_UP': + return left + ? `${left}이(가) 최근 ${sp}봉 구간에서 상승 추세인 상태` + : `최근 ${sp}봉 구간 상승 추세`; + case 'SLOPE_DOWN': + return left + ? `${left}이(가) 최근 ${sp}봉 구간에서 하락 추세인 상태` + : `최근 ${sp}봉 구간 하락 추세`; + case 'ABOVE_CLOUD': + return '종가가 일목균형표 구름대 위에 있는 상태'; + case 'BELOW_CLOUD': + return '종가가 일목균형표 구름대 아래에 있는 상태'; + case 'CLOUD_BREAK_UP': + return '종가가 구름 상단을 상향 돌파하는 순간'; + case 'CLOUD_BREAK_DOWN': + return '종가가 구름 하단을 하향 이탈하는 순간'; + default: + if (left && right) { + return `${left} — ${condVerb(condType)} — ${right}`; + } + return condVerb(condType); + } +} + +function buildTitle(left: string, right: string, condType: string): string { + const verb = condVerb(condType); + if (condType === 'SLOPE_UP' || condType === 'SLOPE_DOWN') { + return left ? `${left} ${verb}` : verb; + } + if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(condType)) { + return CONDITION_LABEL[condType] ?? verb; + } + if (left && right) { + return `${left} ${verb} ${right}`; + } + if (left) return `${left} ${verb}`; + if (right) return `${verb} ${right}`; + return verb; +} + +/** 프리셋 patch + DEF 기준으로 목록 제목·설명을 구체화 */ +export function enrichConditionPresetLabels( + preset: PresetLike, + indicatorType: string, + def: DefType, + signalType: 'buy' | 'sell', +): PresetLike { + const patch = preset.patch; + const condType = patch.conditionType ?? 'GT'; + + if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN', 'IN_CLOUD'].includes(condType)) { + const label = CONDITION_LABEL[condType] ?? preset.label; + const description = buildDescription('', '', condType); + return { ...preset, label, description }; + } + + const mockCond: ConditionDSL = { + indicatorType, + conditionType: condType, + leftField: patch.leftField, + rightField: patch.rightField, + }; + + const left = patch.leftField && patch.leftField !== 'NONE' + ? resolveFieldLabel(indicatorType, patch.leftField, def, signalType, mockCond) + : ''; + const right = patch.rightField && patch.rightField !== 'NONE' + ? resolveFieldLabel(indicatorType, patch.rightField, def, signalType, mockCond) + : ''; + + if (!left && !right) { + return preset; + } + + return { + ...preset, + label: buildTitle(left, right, condType), + description: buildDescription(left, right, condType, patch.slopePeriod), + }; +} diff --git a/frontend/src/utils/conditionPresets.ts b/frontend/src/utils/conditionPresets.ts index 1c4d494..fd5ef61 100644 --- a/frontend/src/utils/conditionPresets.ts +++ b/frontend/src/utils/conditionPresets.ts @@ -11,6 +11,10 @@ import { THRESHOLD_HL_UNDER, } from './thresholdSymbols'; import { nhPriorField, nlPriorField } from './priceExtremeIndicators'; +import { enrichConditionPresetLabels } from './conditionPresetLabels'; +import { getPathBreakoutPresets, isCompoundConditionPreset } from './conditionPathPresets'; +import { genId } from './strategyEditorShared'; +import type { LogicNode } from './strategyTypes'; export type ConditionPresetGraph = | 'cross_up' @@ -21,7 +25,12 @@ export type ConditionPresetGraph = | 'slope_down' | 'dual_cross_up' | 'dual_cross_down' - | 'between'; + | 'between' + | 'path_first_cross' + | 'path_pullback_recross' + | 'path_deep_recovery'; + +export type ConditionPresetCategory = 'basic' | 'path'; export type ConditionPreset = { id: string; @@ -29,6 +38,9 @@ export type ConditionPreset = { description: string; graph: ConditionPresetGraph; patch: Partial; + /** 복합(AND) — 여러 조건을 한 번에 구성 */ + compound?: Partial[]; + category?: ConditionPresetCategory; tags?: ('buy' | 'sell')[]; }; @@ -75,7 +87,7 @@ function compare( function slope(up: boolean, left: string, tags?: ('buy' | 'sell')[]): ConditionPreset { return { - id: up ? 'slope_up' : 'slope_down', + id: up ? `slope_up_${left}` : `slope_down_${left}`, label: up ? '상승 기울기' : '하락 기울기', description: up ? '최근 구간 상승 추세' : '최근 구간 하락 추세', graph: up ? 'slope_up' : 'slope_down', @@ -269,15 +281,25 @@ export function getConditionPresetsForIndicator( signalType: 'buy' | 'sell', ): ConditionPreset[] { const builder = PRESET_BUILDERS[indicatorType]; - if (!builder) return []; - const all = builder(def, signalType); + if (!builder) return getPathBreakoutPresets(indicatorType, def, signalType); + const basic = builder(def, signalType); + const path = getPathBreakoutPresets(indicatorType, def, signalType); + const all = [...basic, ...path]; const seen = new Set(); - return all.filter(p => { - const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}:${p.label}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + return all + .filter(p => { + if (isCompoundConditionPreset(p)) { + return !seen.has(p.id); + } + const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map(p => { + if (isCompoundConditionPreset(p)) return p; + return enrichConditionPresetLabels(p, indicatorType, def, signalType); + }); } export function applyConditionPreset( @@ -303,3 +325,32 @@ export function applyConditionPreset( const inherited = initConditionPeriodsInherit(cond.indicatorType, merged, def); return applyCondTypeDefaults(inherited, condType, def); } + +/** 복합 프리셋 → AND 노드 (동일 id 유지) */ +export function applyCompoundConditionPreset( + nodeId: string, + cond: ConditionDSL, + preset: ConditionPreset, + def: DefType, +): LogicNode { + const patches = preset.compound ?? []; + const children: LogicNode[] = patches.map(patch => { + const single: ConditionPreset = { + id: `${preset.id}_part`, + label: '', + description: '', + graph: preset.graph, + patch, + }; + return { + id: genId(), + type: 'CONDITION', + condition: applyConditionPreset(cond, single, def), + }; + }); + return { + id: nodeId, + type: 'AND', + children, + }; +}