234 lines
8.9 KiB
TypeScript
234 lines
8.9 KiB
TypeScript
/**
|
|
* 전략 조건 읽기전용 뷰 — TradeAlertModal 내 "전략 조건" 탭에 표시
|
|
* LogicNode 트리를 전략 편집기와 유사한 카드 형태로 렌더링한다.
|
|
*/
|
|
import React from 'react';
|
|
import type { LogicNode } from '../../utils/strategyTypes';
|
|
import { CONDITION_LABEL, resolveCandleRangeMode, candleRangeWindowBars } from '../../utils/strategyTypes';
|
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
|
import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories';
|
|
import { nodeToText } from '../../utils/strategyEditorShared';
|
|
import { formatStrategyCandleLabel } from '../../utils/strategyStartNodes';
|
|
import type { StrategyDto } from '../../utils/backendApi';
|
|
|
|
/* ── nodeToText 출력에서 조건대상1 / 조건대상2 분리 ─────────────── */
|
|
/** nodeToText 가 COND_OPTIONS 라벨로 출력하는 문자열 목록 (긴 것 우선으로 체크) */
|
|
const COND_SEARCH = [
|
|
'구름 상향 돌파', '구름 하향 돌파',
|
|
'선행스팬1 상향돌파 선행스팬2', '선행스팬1 하향돌파 선행스팬2',
|
|
'선행스팬1 > 선행스팬2', '선행스팬1 < 선행스팬2',
|
|
'후행스팬 > 가격', '후행스팬 < 가격',
|
|
'상향 돌파', '하향 돌파',
|
|
'상승 기울기', '하락 기울기',
|
|
'이상 (>=)', '이하 (<=)',
|
|
'초과 (>)', '미만 (<)',
|
|
'같음 (==)', '다름 (!=)',
|
|
'차이 > 값', '차이 < 값',
|
|
'N일 연속 유지',
|
|
'구름 위', '구름 아래', '구름 안',
|
|
];
|
|
|
|
function splitDescByCondType(descText: string): { left: string; right: string; mid: string } {
|
|
for (const cond of COND_SEARCH) {
|
|
const idx = descText.indexOf(cond);
|
|
if (idx > 0) {
|
|
return {
|
|
left: descText.slice(0, idx).trim(),
|
|
mid: cond,
|
|
right: descText.slice(idx + cond.length).trim(),
|
|
};
|
|
}
|
|
}
|
|
return { left: descText, mid: '', right: '' };
|
|
}
|
|
|
|
/* ── 개별 조건 카드 ────────────────────────────────────────────── */
|
|
const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => {
|
|
const cond = node.condition;
|
|
if (!cond) return null;
|
|
|
|
const indType = cond.indicatorType;
|
|
const indLabel = getStrategyIndicatorDisplayName(indType);
|
|
const cat = getIndicatorPaletteCategory(indType);
|
|
const fullText = nodeToText(node);
|
|
|
|
const prefix = `${indLabel} - `;
|
|
const descText = fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText;
|
|
const { left, mid: condStr, right } = splitDescByCondType(descText);
|
|
|
|
const rangeMode = resolveCandleRangeMode(cond);
|
|
const rangeN = candleRangeWindowBars(cond);
|
|
const candleRangeLabel = rangeMode === 'CURRENT'
|
|
? '현재 캔들'
|
|
: rangeMode === 'EXISTS_IN'
|
|
? `최근 ${rangeN}봉 내 존재`
|
|
: `최근 ${rangeN}봉 내 미존재`;
|
|
|
|
const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType);
|
|
|
|
return (
|
|
<div className="scv-cond-card">
|
|
<div className="scv-cond-header">
|
|
<span className={`se-ind-chip se-ind-chip--${cat} scv-ind-chip`}>{indType}</span>
|
|
<span className="scv-cond-title">{indLabel} - {descText}</span>
|
|
</div>
|
|
<div className="scv-cond-fields">
|
|
<div className="scv-cond-field">
|
|
<span className="scv-cond-field-lbl">캔들범위</span>
|
|
<span className="scv-cond-field-val">{candleRangeLabel}</span>
|
|
</div>
|
|
{left && (
|
|
<div className="scv-cond-field">
|
|
<span className="scv-cond-field-lbl">조건대상1</span>
|
|
<span className="scv-cond-field-val">{left}</span>
|
|
</div>
|
|
)}
|
|
{right && (
|
|
<div className="scv-cond-field">
|
|
<span className="scv-cond-field-lbl">조건대상2</span>
|
|
<span className="scv-cond-field-val">{right}</span>
|
|
</div>
|
|
)}
|
|
<div className="scv-cond-field">
|
|
<span className="scv-cond-field-lbl">조건</span>
|
|
<span className="scv-cond-field-val">{condLabelDisplay}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ── 노드 트리 재귀 렌더러 ─────────────────────────────────────── */
|
|
function renderNode(node: LogicNode): React.ReactNode {
|
|
if (node.type === 'CONDITION') {
|
|
return <ConditionCard key={node.id} node={node} />;
|
|
}
|
|
|
|
if (node.type === 'AND' || node.type === 'OR') {
|
|
const op = node.type as 'AND' | 'OR';
|
|
const opKo = op === 'AND' ? 'AND (그리고)' : 'OR (또는)';
|
|
const children = node.children ?? [];
|
|
return (
|
|
<div key={node.id} className={`scv-group scv-group--${op.toLowerCase()}`}>
|
|
<div className="scv-group-header">
|
|
<span className={`scv-group-badge scv-group-badge--${op.toLowerCase()}`}>{op}</span>
|
|
<span className="scv-group-label">{opKo}</span>
|
|
<div className="scv-group-op-toggle" aria-hidden>
|
|
<span className={op === 'AND' ? 'scv-op-active' : 'scv-op-dim'}>AND</span>
|
|
<span className={op === 'OR' ? 'scv-op-active' : 'scv-op-dim'}>OR</span>
|
|
</div>
|
|
</div>
|
|
<div className="scv-group-body">
|
|
{children.map(child => renderNode(child))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (node.type === 'NOT') {
|
|
const inner = node.children?.[0];
|
|
return (
|
|
<div key={node.id} className="scv-group scv-group--not">
|
|
<div className="scv-group-header">
|
|
<span className="scv-group-badge scv-group-badge--not">NOT</span>
|
|
<span className="scv-group-label">NOT (부정)</span>
|
|
</div>
|
|
<div className="scv-group-body">
|
|
{inner ? renderNode(inner) : <span className="scv-empty">빈 NOT</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (node.type === 'TIMEFRAME') {
|
|
const inner = node.children?.[0];
|
|
const types = node.candleTypes?.length ? node.candleTypes : [node.candleType ?? '1m'];
|
|
const tfLabel = types.map(formatStrategyCandleLabel).join(' · ');
|
|
return (
|
|
<div key={node.id} className="scv-timeframe">
|
|
<div className="scv-timeframe-header">
|
|
<span className="scv-timeframe-dot">●</span>
|
|
<span>START</span>
|
|
<span className="scv-timeframe-badge">{tfLabel}</span>
|
|
</div>
|
|
{inner
|
|
? <div className="scv-timeframe-body">{renderNode(inner)}</div>
|
|
: <span className="scv-empty">빈 조건</span>
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/* ── 매수/매도 조건 트리 ─────────────────────────────────────────── */
|
|
const ConditionTree: React.FC<{ root: LogicNode | null | undefined }> = ({ root }) => {
|
|
if (!root) return <p className="scv-empty">조건이 없습니다.</p>;
|
|
return <div className="scv-tree">{renderNode(root)}</div>;
|
|
};
|
|
|
|
/* ── 메인 컴포넌트 ───────────────────────────────────────────────── */
|
|
interface Props {
|
|
strategy: StrategyDto;
|
|
/** 알림의 신호 타입으로 초기 탭 결정 */
|
|
signalType?: 'BUY' | 'SELL';
|
|
}
|
|
|
|
const StrategyConditionView: React.FC<Props> = ({ strategy, signalType }) => {
|
|
const hasBuy = Boolean(strategy.buyCondition);
|
|
const hasSell = Boolean(strategy.sellCondition);
|
|
const showBoth = hasBuy && hasSell;
|
|
|
|
const [tab, setTab] = React.useState<'buy' | 'sell'>(
|
|
signalType === 'SELL' && hasSell ? 'sell' : 'buy',
|
|
);
|
|
|
|
const buyRoot = strategy.buyCondition as LogicNode | null | undefined;
|
|
const sellRoot = strategy.sellCondition as LogicNode | null | undefined;
|
|
|
|
return (
|
|
<div className="scv-root">
|
|
{showBoth && (
|
|
<div className="scv-signal-tabs">
|
|
<button
|
|
type="button"
|
|
className={`scv-signal-tab${tab === 'buy' ? ' scv-signal-tab--active scv-signal-tab--buy' : ''}`}
|
|
onClick={() => setTab('buy')}
|
|
>
|
|
매수 조건 (Entry)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`scv-signal-tab${tab === 'sell' ? ' scv-signal-tab--active scv-signal-tab--sell' : ''}`}
|
|
onClick={() => setTab('sell')}
|
|
>
|
|
매도 조건 (Exit)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!showBoth && hasBuy && (
|
|
<div className="scv-signal-label scv-signal-label--buy">매수 조건 (Entry)</div>
|
|
)}
|
|
{!showBoth && hasSell && (
|
|
<div className="scv-signal-label scv-signal-label--sell">매도 조건 (Exit)</div>
|
|
)}
|
|
|
|
<div className="scv-body">
|
|
{showBoth ? (
|
|
tab === 'buy'
|
|
? <ConditionTree root={buyRoot} />
|
|
: <ConditionTree root={sellRoot} />
|
|
) : hasBuy ? (
|
|
<ConditionTree root={buyRoot} />
|
|
) : (
|
|
<ConditionTree root={sellRoot} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StrategyConditionView;
|