From 5685deb2c7d75b69344fa68c75313c7ec2464e15 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 24 May 2026 13:03:39 +0900 Subject: [PATCH] =?UTF-8?q?33=EB=B3=80=EA=B3=A1=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 17 ++- .../src/components/StrategyEditorPage.tsx | 35 +++--- frontend/src/components/StrategyPage.tsx | 40 +++---- frontend/src/styles/strategyEditor.css | 23 +++- frontend/src/utils/strategyPresets.ts | 101 ++++++++++++++++++ 5 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 frontend/src/utils/strategyPresets.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 2153793..490e7a1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6310,8 +6310,13 @@ html.theme-blue { background: var(--bg3); border-radius: var(--radius); } .sp-pal-tmpl { - display: flex; align-items: center; gap: 8px; padding: 10px 8px; - border-bottom: 1px solid var(--border); cursor: pointer; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + padding: 10px 8px; + border-bottom: 1px solid var(--border); + cursor: pointer; font-size: 13px; } .sp-pal-tmpl:hover { background: var(--bg3); } @@ -6320,7 +6325,13 @@ html.theme-blue { } .sp-tmpl-badge--buy { background: rgba(72,199,116,.2); color: #48c774; } .sp-tmpl-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; } -.sp-tmpl-name { flex: 1; color: var(--text); } +.sp-tmpl-name { flex: 1 1 auto; color: var(--text); } +.sp-tmpl-desc { + flex: 1 1 100%; + font-size: 11px; + line-height: 1.35; + color: var(--text2); +} /* ── 모달 ─────────────────────────────────────────────────────────────────── */ .sp-modal-overlay { diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 721c8fe..27de866 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -37,6 +37,11 @@ import { saveEditorMode, type StrategyEditorMode, } from '../utils/strategyEditorModeStorage'; +import { + getStrategyTemplates, + simpleTemplateToNode, + type StrategyTemplateDef, +} from '../utils/strategyPresets'; import PaletteChip from './strategyEditor/PaletteChip'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import '../styles/strategyEditor.css'; @@ -396,27 +401,24 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro else setCurrentRoot(mergeAtRoot(root, newNode, false)); }, [signalTab, DEF, currentRoot, setCurrentRoot]); - const templates = useMemo(() => [ - { ind: 'MA', cond: 'CROSS_UP', l: `MA${DEF.maLines[0]}`, r: `MA${DEF.maLines[1]}`, signal: 'buy' as const, label: 'MA 골든크로스 매수' }, - { ind: 'MA', cond: 'CROSS_DOWN', l: `MA${DEF.maLines[0]}`, r: `MA${DEF.maLines[1]}`, signal: 'sell' as const, label: 'MA 데드크로스 매도' }, - { ind: 'RSI', cond: 'LTE', l: 'RSI_VALUE', r: 'K_30', signal: 'buy' as const, label: 'RSI 과매도 매수' }, - { ind: 'RSI', cond: 'GTE', l: 'RSI_VALUE', r: 'K_70', signal: 'sell' as const, label: 'RSI 과매수 매도' }, - { ind: 'MACD', cond: 'CROSS_UP', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'buy' as const, label: 'MACD 상향 돌파' }, - { ind: 'MACD', cond: 'CROSS_DOWN', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'sell' as const, label: 'MACD 하향 돌파' }, - ], [DEF.maLines]); + const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); - const handleTemplate = (tmpl: typeof templates[0]) => { - const newNode: LogicNode = { - id: genId(), type: 'CONDITION', - condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 }, - }; + const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => { if (tmpl.signal !== signalTab) switchSignalTab(tmpl.signal); - const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition; + const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; + + if (tmpl.kind === 'composite') { + setRoot(tmpl.build()); + showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); + return; + } + + const newNode = simpleTemplateToNode(tmpl); if (!root) setRoot(newNode); else setRoot(mergeAtRoot(root, newNode, false)); showSnack(`"${tmpl.label}" 추가됨`); - }; + }, [signalTab, switchSignalTab, buyCondition, sellCondition, setBuyCondition, setSellCondition]); const operators = [ { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, @@ -705,6 +707,9 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro {templates.map((t, i) => ( ))} diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index 9945b49..38b2061 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -13,6 +13,11 @@ import { type StrategyDto as ApiStrategyDto, } from '../utils/backendApi'; import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry'; +import { + getStrategyTemplates, + simpleTemplateToNode, + type StrategyTemplateDef, +} from '../utils/strategyPresets'; // ─── 타입 ───────────────────────────────────────────────────────────────────── type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION'; @@ -1184,14 +1189,19 @@ export const StrategyPage: React.FC = ({ activeIndicators = [] }) => { }; // ── 템플릿 선택 ───────────────────────────────────────────────────────────── - const handleTemplateSelect = (tmpl: { ind: string; cond: string; l: string; r: string; signal: 'buy'|'sell'; label: string }) => { - const newNode: LogicNode = { - id: genId(), type: 'CONDITION', - condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 }, - }; + const handleTemplateSelect = (tmpl: StrategyTemplateDef) => { if (tmpl.signal !== currentTab) setCurrentTab(tmpl.signal); - const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition; + const root = tmpl.signal === 'buy' ? buyCondition : sellCondition; + + if (tmpl.kind === 'composite') { + setRoot(tmpl.build()); + setRightTab('preview'); + showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); + return; + } + + const newNode = simpleTemplateToNode(tmpl); if (!root) { setRoot(newNode); } else { setRoot(mergeAtRoot(root, newNode, false)); } setRightTab('preview'); @@ -1217,20 +1227,7 @@ export const StrategyPage: React.FC = ({ activeIndicators = [] }) => { 'TRIX:TRIX', 'VOLUME_OSC:VolumeOSC', 'VR:VR', 'DISPARITY:이격도', 'VOLUME:거래량', ].map(s => { const [v,l] = s.split(':'); return { type:'indicator' as const, value:v, label:l, color:'ind' }; }); - const templates = [ - { ind:'MA', cond:'CROSS_UP', l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}`, signal:'buy' as const, label:'MA 골든크로스 매수' }, - { ind:'MA', cond:'CROSS_DOWN', l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}`, signal:'sell' as const, label:'MA 데드크로스 매도' }, - { ind:'RSI', cond:'LTE', l:'RSI_VALUE', r:'OVERSOLD_30', signal:'buy' as const, label:'RSI 과매도 매수' }, - { ind:'RSI', cond:'GTE', l:'RSI_VALUE', r:'OVERBOUGHT_70', signal:'sell' as const, label:'RSI 과매수 매도' }, - { ind:'MACD', cond:'CROSS_UP', l:'MACD_LINE', r:'SIGNAL_LINE', signal:'buy' as const, label:'MACD 시그널 상향 돌파' }, - { ind:'MACD', cond:'CROSS_DOWN', l:'MACD_LINE', r:'SIGNAL_LINE', signal:'sell' as const, label:'MACD 시그널 하향 돌파' }, - { ind:'BOLLINGER', cond:'CROSS_UP', l:'CLOSE_PRICE', r:'LOWER_BAND', signal:'buy' as const, label:'볼린저 하단 돌파 매수' }, - { ind:'BOLLINGER', cond:'CROSS_UP', l:'CLOSE_PRICE', r:'UPPER_BAND', signal:'sell' as const, label:'볼린저 상단 돌파 매도' }, - { ind:'CCI', cond:'CROSS_UP', l:'CCI_VALUE', r:'OVERSOLD_NEG100', signal:'buy' as const, label:'CCI 과매도 반등' }, - { ind:'PSYCHOLOGICAL', cond:'CROSS_UP', l:'PSY_VALUE', r:'OVERBOUGHT_75', signal:'buy' as const, label:'심리도 상향 돌파' }, - { ind:'STOCHASTIC', cond:'CROSS_UP', l:'STOCH_K', r:'STOCH_D', signal:'buy' as const, label:'스토캐스틱 골든크로스' }, - { ind:'ADX', cond:'CROSS_UP', l:'ADX_VALUE', r:'ADX_25', signal:'buy' as const, label:'ADX 중앙선 상향 돌파' }, - ]; + const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); const currentRoot = getCurrent(); @@ -1548,6 +1545,9 @@ export const StrategyPage: React.FC = ({ activeIndicators = [] }) => { {t.signal === 'buy' ? '매수' : '매도'} {t.label} + {'description' in t && t.description && ( + {t.description} + )} ))} diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index d485ba5..7fb26ae 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -996,20 +996,33 @@ .se-template-list { padding: 10px; overflow-y: auto; flex: 1; } .se-template-item { - display: flex; align-items: center; justify-content: space-between; - width: 100%; padding: 10px 12px; margin-bottom: 6px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + width: 100%; + padding: 10px 12px; + margin-bottom: 6px; border-radius: 8px; border: 1px solid var(--se-border); background: var(--se-palette-card-bg); - color: var(--se-text); cursor: pointer; text-align: left; + color: var(--se-text); + cursor: pointer; + text-align: left; transition: border-color 0.12s, box-shadow 0.12s; } .se-template-item:hover { border-color: var(--se-palette-hover-border); box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent); } -.se-template-label { font-size: 0.76rem; } -.se-template-signal { font-size: 0.62rem; font-weight: 700; padding: 2px 6px; border-radius: 4px; } +.se-template-label { font-size: 0.76rem; flex: 1 1 auto; } +.se-template-desc { + flex: 1 1 100%; + font-size: 0.66rem; + line-height: 1.35; + color: var(--se-text-muted); +} +.se-template-signal { font-size: 0.62rem; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-left: auto; } .se-template-signal--buy { color: var(--se-buy); background: color-mix(in srgb, var(--se-buy) 12%, transparent); } .se-template-signal--sell { color: var(--se-sell); background: color-mix(in srgb, var(--se-sell) 12%, transparent); } diff --git a/frontend/src/utils/strategyPresets.ts b/frontend/src/utils/strategyPresets.ts new file mode 100644 index 0000000..847fbca --- /dev/null +++ b/frontend/src/utils/strategyPresets.ts @@ -0,0 +1,101 @@ +import type { LogicNode } from './strategyTypes'; +import { genId, type DefType } from './strategyEditorShared'; + +export type SimpleStrategyTemplate = { + kind: 'simple'; + signal: 'buy' | 'sell'; + label: string; + description?: string; + ind: string; + cond: string; + l: string; + r: string; +}; + +export type CompositeStrategyTemplate = { + kind: 'composite'; + signal: 'buy' | 'sell'; + label: string; + description: string; + build: () => LogicNode; +}; + +export type StrategyTemplateDef = SimpleStrategyTemplate | CompositeStrategyTemplate; + +function condition( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + candleRange = 1, +): LogicNode { + return { + id: genId(), + type: 'CONDITION', + condition: { indicatorType, conditionType, leftField, rightField, candleRange }, + }; +} + +function andTree(...children: LogicNode[]): LogicNode { + return { id: genId(), type: 'AND', children }; +} + +/** 33변곡 매수 — 바닥권 시간변곡(약 33거래일 주기) 전환 구간 */ +export function build33InflectionBuyTree(): LogicNode { + return andTree( + condition('RSI', 'CROSS_UP', 'RSI_VALUE', 'OVERSOLD_30'), + condition('MA', 'CROSS_UP', 'MA5', 'MA20'), + condition('MA', 'CROSS_UP', 'CLOSE_PRICE', 'MA20'), + condition('MACD', 'CROSS_UP', 'MACD_LINE', 'SIGNAL_LINE'), + condition('DISPARITY', 'CROSS_UP', 'DISPARITY20', 'OVERSOLD_95'), + ); +} + +/** 33변곡 매도 — 상승 5파 끝(33·36·38일 변곡) 구간 이탈 */ +export function build33InflectionSellTree(): LogicNode { + return andTree( + condition('RSI', 'GTE', 'RSI_VALUE', 'OVERBOUGHT_70'), + condition('MA', 'CROSS_DOWN', 'MA5', 'MA20'), + condition('MA', 'CROSS_DOWN', 'CLOSE_PRICE', 'MA20'), + condition('MACD', 'CROSS_DOWN', 'MACD_LINE', 'SIGNAL_LINE'), + condition('DISPARITY', 'GTE', 'DISPARITY20', 'OVERBOUGHT_105'), + ); +} + +export function getStrategyTemplates(def: DefType): StrategyTemplateDef[] { + const maShort = `MA${def.maLines[0]}`; + const maMid = `MA${def.maLines[1]}`; + + return [ + { kind: 'simple', ind: 'MA', cond: 'CROSS_UP', l: maShort, r: maMid, signal: 'buy', label: 'MA 골든크로스 매수' }, + { kind: 'simple', ind: 'MA', cond: 'CROSS_DOWN', l: maShort, r: maMid, signal: 'sell', label: 'MA 데드크로스 매도' }, + { kind: 'simple', ind: 'RSI', cond: 'LTE', l: 'RSI_VALUE', r: 'OVERSOLD_30', signal: 'buy', label: 'RSI 과매도 매수' }, + { kind: 'simple', ind: 'RSI', cond: 'GTE', l: 'RSI_VALUE', r: 'OVERBOUGHT_70', signal: 'sell', label: 'RSI 과매수 매도' }, + { kind: 'simple', ind: 'MACD', cond: 'CROSS_UP', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'buy', label: 'MACD 상향 돌파' }, + { kind: 'simple', ind: 'MACD', cond: 'CROSS_DOWN', l: 'MACD_LINE', r: 'SIGNAL_LINE', signal: 'sell', label: 'MACD 하향 돌파' }, + { kind: 'simple', ind: 'BOLLINGER', cond: 'CROSS_UP', l: 'CLOSE_PRICE', r: 'LOWER_BAND', signal: 'buy', label: '볼린저 하단 돌파 매수' }, + { kind: 'simple', ind: 'BOLLINGER', cond: 'CROSS_UP', l: 'CLOSE_PRICE', r: 'UPPER_BAND', signal: 'sell', label: '볼린저 상단 돌파 매도' }, + { kind: 'simple', ind: 'CCI', cond: 'CROSS_UP', l: 'CCI_VALUE', r: 'OVERSOLD_NEG100', signal: 'buy', label: 'CCI 과매도 반등' }, + { kind: 'simple', ind: 'PSYCHOLOGICAL', cond: 'CROSS_UP', l: 'PSY_VALUE', r: 'OVERBOUGHT_75', signal: 'buy', label: '심리도 상향 돌파' }, + { kind: 'simple', ind: 'STOCHASTIC', cond: 'CROSS_UP', l: 'STOCH_K', r: 'STOCH_D', signal: 'buy', label: '스토캐스틱 골든크로스' }, + { kind: 'simple', ind: 'ADX', cond: 'CROSS_UP', l: 'ADX_VALUE', r: 'ADX_25', signal: 'buy', label: 'ADX 중앙선 상향 돌파' }, + { + kind: 'composite', + signal: 'buy', + label: '33변곡 매수', + description: '바닥권 시간변곡 — RSI 과매도 탈출 + MA 골든크로스 + 20일선·이격도 돌파', + build: build33InflectionBuyTree, + }, + { + kind: 'composite', + signal: 'sell', + label: '33변곡 매도', + description: '상승 5파 끝 변곡 — RSI 과매수 + MA 데드크로스 + 20일선·이격도 이탈', + build: build33InflectionSellTree, + }, + ]; +} + +export function simpleTemplateToNode(tmpl: SimpleStrategyTemplate): LogicNode { + return condition(tmpl.ind, tmpl.cond, tmpl.l, tmpl.r); +}