복합지표 추가

This commit is contained in:
Macbook
2026-06-11 22:53:19 +09:00
parent 280c187021
commit 05c15ec92b
17 changed files with 817 additions and 77 deletions
+37
View File
@@ -282,6 +282,43 @@ export function makeCompositeCondition(
});
}
/** 기간 교차 복합지표 — 지표 종류 변경 (조건·시간봉 유지, 기간은 새 지표 기본값) */
export function switchCompositeIndicatorType(
cond: ConditionDSL,
newIndicatorType: string,
def: CompositePeriodDef,
): ConditionDSL {
if (!cond.composite || newIndicatorType === cond.indicatorType) {
return normalizeCompositeCondition(cond);
}
const { short, long } = getCompositeDefaultPeriods(newIndicatorType, def);
const base = {
conditionType: cond.conditionType,
leftCandleType: cond.leftCandleType ?? DEFAULT_START_CANDLE,
rightCandleType: cond.rightCandleType ?? DEFAULT_START_CANDLE,
candleRange: cond.candleRange ?? 1,
valuePeriodOverride: false as const,
rightPeriodOverride: false as const,
thresholdOverride: false as const,
};
if (isDonchianComposite(newIndicatorType)) {
return syncDonchianCompositeFields({
indicatorType: newIndicatorType,
composite: true,
...base,
leftPeriod: short,
rightPeriod: long,
});
}
return syncCompositeFields({
indicatorType: newIndicatorType,
composite: true,
...base,
leftPeriod: short,
rightPeriod: long,
});
}
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
export function getDonchianFieldOpts(
allPeriods: number[],
+194
View File
@@ -0,0 +1,194 @@
/** Stochastic 과열(≥80) × 보조지표 중앙선 — 순차 OR 복합 매수 조건 */
import type { ConditionDSL, LogicNode } from './strategyTypes';
import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods';
import { genId } from './strategyNodeIds';
import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER } from './thresholdSymbols';
export const STOCH_OVERBOUGHT_PAIR_VALUE = 'STOCH_OVERBOUGHT_PAIR';
export const STOCH_PAIR_SECONDARY_OPTIONS: { value: string; label: string }[] = [
{ value: 'CCI', label: 'CCI' },
{ value: 'RSI', label: 'RSI' },
{ value: 'ADX', label: 'ADX' },
{ value: 'WILLIAMS_R', label: 'Williams %R' },
{ value: 'TRIX', label: 'TRIX' },
{ value: 'VR', label: 'VR' },
{ value: 'PSYCHOLOGICAL', label: '심리도' },
{ value: 'NEW_PSYCHOLOGICAL', label: '신심리도' },
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도' },
{ value: 'BWI', label: 'BWI' },
{ value: 'VOLUME_OSC', label: 'Volume OSC' },
];
const SECONDARY_VALUE_FIELD: 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: 'NEW_PSY_VALUE',
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
BWI: 'BWI_VALUE',
VOLUME_OSC: 'VOLUME_OSC_VALUE',
};
function secondaryLabel(indicatorType: string): string {
return STOCH_PAIR_SECONDARY_OPTIONS.find(o => o.value === indicatorType)?.label ?? indicatorType;
}
export function isStochOverboughtPairPaletteValue(value: string): boolean {
return value === STOCH_OVERBOUGHT_PAIR_VALUE;
}
export function isStochOverboughtPairRoot(node: LogicNode): boolean {
return node.type === 'OR' && !!node.stochPair?.secondaryIndicatorType;
}
export function getSecondaryValueField(indicatorType: string): string {
return SECONDARY_VALUE_FIELD[indicatorType] ?? `${indicatorType}_VALUE`;
}
function makePairCondition(
indicatorType: string,
conditionType: string,
leftField: string,
rightField: string,
def: IndicatorPeriodDef,
): LogicNode {
const base: ConditionDSL = {
indicatorType,
conditionType,
leftField,
rightField,
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
};
return {
id: genId(),
type: 'CONDITION',
condition: initConditionPeriodsInherit(indicatorType, base, def),
};
}
/** (Stoch≥과열 AND 보조 CROSS_UP 중앙) OR (보조≥중앙 AND Stoch CROSS_UP 과열) */
export function buildStochOverboughtPairTree(
secondaryIndicator: string,
def: IndicatorPeriodDef,
): LogicNode {
const sec = STOCH_PAIR_SECONDARY_OPTIONS.some(o => o.value === secondaryIndicator)
? secondaryIndicator
: 'CCI';
const secField = getSecondaryValueField(sec);
return {
id: genId(),
type: 'OR',
stochPair: { secondaryIndicatorType: sec },
children: [
{
id: genId(),
type: 'AND',
children: [
makePairCondition('STOCHASTIC', 'GTE', 'STOCH_K', THRESHOLD_HL_OVER, def),
makePairCondition(sec, 'CROSS_UP', secField, THRESHOLD_HL_MID, def),
],
},
{
id: genId(),
type: 'AND',
children: [
makePairCondition(sec, 'GTE', secField, THRESHOLD_HL_MID, def),
makePairCondition('STOCHASTIC', 'CROSS_UP', 'STOCH_K', THRESHOLD_HL_OVER, def),
],
},
],
};
}
/** 보조지표 변경 시 기존 OR·AND 노드 id 유지 */
export function setStochPairSecondary(
root: LogicNode,
secondaryIndicator: string,
def: IndicatorPeriodDef,
): LogicNode {
if (!isStochOverboughtPairRoot(root)) return root;
const rebuilt = buildStochOverboughtPairTree(secondaryIndicator, def);
const preserveIds = (next: LogicNode, prev: LogicNode | undefined): LogicNode => ({
...next,
id: prev?.id ?? next.id,
children: next.children?.map((child, i) =>
preserveIds(child, prev?.children?.[i]),
),
});
return preserveIds(rebuilt, root);
}
export function stochPairDisplayName(secondaryIndicator: string): string {
return `Stoch 과열×${secondaryLabel(secondaryIndicator)}`;
}
export function stochPairSummaryText(secondaryIndicator: string): string {
const sec = secondaryLabel(secondaryIndicator);
return `(Stoch≥과열 AND ${sec} 중앙 상향) OR (${sec}≥중앙 AND Stoch 과열 상향)`;
}
export function stochPairPaletteDesc(secondaryIndicator: string): string {
const sec = secondaryLabel(secondaryIndicator);
return `Stoch≥80 + ${sec} 0선(중앙) 돌파 또는 ${sec}≥중앙 + Stoch 과열 돌파`;
}
function nodeContainsId(node: LogicNode, targetId: string): boolean {
if (node.id === targetId) return true;
return (node.children ?? []).some(c => nodeContainsId(c, targetId));
}
/** nodeId를 포함하는 Stoch 과열×보조 OR 루트 탐색 */
export function findStochPairRootContaining(
node: LogicNode | null | undefined,
targetId: string,
): LogicNode | null {
if (!node) return null;
if (isStochOverboughtPairRoot(node) && nodeContainsId(node, targetId)) {
return node;
}
for (const child of node.children ?? []) {
const hit = findStochPairRootContaining(child, targetId);
if (hit) return hit;
}
return null;
}
export function findStochPairRootInForest(
roots: (LogicNode | null | undefined)[],
targetId: string,
): LogicNode | null {
for (const root of roots) {
const hit = findStochPairRootContaining(root, targetId);
if (hit) return hit;
}
return null;
}
/** OR 서브트리 전체를 새 보조지표로 재구성 (노드 id 유지) */
export function patchStochPairInTree(
root: LogicNode | null,
orNodeId: string,
secondaryIndicator: string,
def: IndicatorPeriodDef,
): LogicNode | null {
if (!root) return null;
if (root.id === orNodeId && isStochOverboughtPairRoot(root)) {
return setStochPairSecondary(root, secondaryIndicator, def);
}
if (!root.children?.length) return root;
return {
...root,
children: root.children.map(c => patchStochPairInTree(c, orNodeId, secondaryIndicator, def) ?? c),
};
}
+84 -22
View File
@@ -4,6 +4,7 @@ import type { IndicatorConfig } from '../types/index';
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
import {
COMPOSITE_INDICATOR_ITEMS,
compositeDisplayName,
compositeElementLabel,
compositeFieldLabel,
@@ -11,6 +12,7 @@ import {
normalizeCompositeCondition,
parseDonchianPeriod,
parsePeriodFromCompositeField,
switchCompositeIndicatorType,
syncCompositeFields,
} from '../utils/compositeIndicators';
import {
@@ -21,6 +23,14 @@ import {
syncPriceExtremeSimpleFields,
} from '../utils/priceExtremeIndicators';
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
import {
buildStochOverboughtPairTree,
isStochOverboughtPairPaletteValue,
isStochOverboughtPairRoot,
STOCH_PAIR_SECONDARY_OPTIONS,
stochPairDisplayName,
stochPairSummaryText,
} from '../utils/stochOverboughtPair';
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import {
getCompositeFieldOpts,
@@ -57,6 +67,9 @@ import {
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
import { genId } from './strategyNodeIds';
export { genId };
export interface StrategyDto {
id: number;
@@ -78,9 +91,6 @@ export interface ValidationResult {
export type DefType = typeof DEF_DEFAULTS;
let _cnt = 0;
export const genId = () => `n_${++_cnt}_${Date.now()}`;
/** @deprecated DB(gc_strategy)만 사용 */
export const loadStratsLocal = (): StrategyDto[] => [];
/** @deprecated */
@@ -729,6 +739,10 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
}
if (node.type === 'OR') {
if (isStochOverboughtPairRoot(node)) {
const sec = node.stochPair!.secondaryIndicatorType;
return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`;
}
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
}
@@ -818,9 +832,17 @@ interface CondEditorProps {
signalType: 'buy'|'sell';
onChange: (c: ConditionDSL) => void;
def: DefType;
/** Stoch 과열×보조 복합 — 캔들범위 대신 보조지표 선택 */
stochPairEdit?: {
secondaryIndicator: string;
onSecondaryChange: (indicator: string) => void;
stochLocked?: boolean;
};
}
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
export const CondEditor: React.FC<CondEditorProps> = ({
cond, signalType, onChange, def, stochPairEdit,
}) => {
const normalized = normalizeCompositeCondition(cond);
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
@@ -936,14 +958,15 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
</div>
<div className="sp-cond-row4">
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={normalized.candleRange ?? 1}
onChange={e => onChange({ ...normalized, candleRange: Number(e.target.value) })}>
<option value={1}> </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
<label className="sp-cond-lbl"></label>
<select
className="sp-cond-sel"
value={normalized.indicatorType}
onChange={e => onChange(switchCompositeIndicatorType(normalized, e.target.value, def))}
>
{COMPOSITE_INDICATOR_ITEMS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="sp-cond-field">
@@ -995,17 +1018,41 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
<div className="sp-cond-editor">
{/* 4컬럼 row */}
<div className="sp-cond-row4">
{/* 캔들 범위 */}
{/* 캔들 범위 / Stoch×보조 복합 보조지표 */}
<div className="sp-cond-field">
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
<option value={1}> </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
</select>
{stochPairEdit ? (
stochPairEdit.stochLocked ? (
<>
<label className="sp-cond-lbl"> </label>
<output className="sp-cond-readout">Stochastic %K</output>
</>
) : (
<>
<label className="sp-cond-lbl"></label>
<select
className="sp-cond-sel"
value={stochPairEdit.secondaryIndicator}
onChange={e => stochPairEdit.onSecondaryChange(e.target.value)}
>
{STOCH_PAIR_SECONDARY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</>
)
) : (
<>
<label className="sp-cond-lbl"> </label>
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
<option value={1}> </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
</select>
</>
)}
</div>
{/* 조건대상1 */}
<div className="sp-cond-field">
@@ -1117,17 +1164,29 @@ export type MakeNodeOptions = {
rightPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
/** Stoch 과열×보조 복합 */
stochPair?: boolean;
secondaryIndicator?: string;
};
/** 팔레트 드래그 payload → makeNode 옵션 */
export function makeNodeOptionsFromPalette(data: {
composite?: boolean;
stochPair?: boolean;
secondaryIndicator?: string;
period?: number;
shortPeriod?: number;
longPeriod?: number;
leftCandleType?: string;
rightCandleType?: string;
value?: string;
}): MakeNodeOptions | undefined {
if (data.stochPair || (data.value && isStochOverboughtPairPaletteValue(data.value))) {
return {
stochPair: true,
secondaryIndicator: data.secondaryIndicator ?? 'CCI',
};
}
if (data.composite) {
return {
composite: true,
@@ -1146,6 +1205,9 @@ export const makeNode = (
options?: MakeNodeOptions,
): LogicNode => {
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) {
return buildStochOverboughtPairTree(options?.secondaryIndicator ?? 'CCI', DEF);
}
if (options?.composite) {
let condition = makeCompositeCondition(value, signalType, DEF);
condition = {
+7 -2
View File
@@ -40,6 +40,8 @@ export type StrategyFlowNodeData = {
onDeleteStart?: (startId: string) => void;
onDelete?: (id: string) => void;
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
/** Stoch 과열×보조 복합 — 보조지표 변경 */
onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void;
/** AND ↔ OR 전환 */
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
@@ -472,7 +474,7 @@ function layoutTree(
signalTab: 'buy' | 'sell',
callbacks: Pick<
StrategyFlowNodeData,
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
>,
dragOverId: string | null,
@@ -494,6 +496,7 @@ function layoutTree(
signalTab,
onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition,
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
onChangeLogicGateType: callbacks.onChangeLogicGateType,
onDropTarget: callbacks.onDropTarget,
onDragOverTarget: callbacks.onDragOverTarget,
@@ -623,7 +626,7 @@ function appendOrphanFlowNodes(
signalTab: 'buy' | 'sell',
callbacks: Pick<
StrategyFlowNodeData,
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
>,
): void {
@@ -645,6 +648,7 @@ function appendOrphanFlowNodes(
signalTab,
onDelete: callbacks.onDelete,
onUpdateCondition: callbacks.onUpdateCondition,
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
onChangeLogicGateType: callbacks.onChangeLogicGateType,
onDropTarget: callbacks.onDropTarget,
onDragOverTarget: callbacks.onDragOverTarget,
@@ -668,6 +672,7 @@ export function logicNodeToFlow(
StrategyFlowNodeData,
| 'onDelete'
| 'onUpdateCondition'
| 'onUpdateStochPairSecondary'
| 'onDropTarget'
| 'onDragOverTarget'
| 'onDragLeaveTarget'
+3
View File
@@ -0,0 +1,3 @@
/** 전략 DSL 노드 id — 순환 import 방지를 위해 별도 모듈 */
let _cnt = 0;
export const genId = () => `n_${++_cnt}_${Date.now()}`;
+19 -4
View File
@@ -1,6 +1,10 @@
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
import {
STOCH_OVERBOUGHT_PAIR_VALUE,
stochPairPaletteDesc,
} from './stochOverboughtPair';
export type PaletteItemKind = 'auxiliary' | 'composite';
@@ -16,6 +20,8 @@ export interface PaletteItem {
/** 복합지표 단기·장기 기간 */
shortPeriod?: number;
longPeriod?: number;
/** Stoch 과열×보조 복합 — 기본 보조지표 (CCI 등) */
secondaryIndicator?: string;
builtIn?: boolean;
}
@@ -40,20 +46,29 @@ export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
{ value: 'NEW_LOW', label: '신저가', desc: '직전 N봉 최저가 이탈 — 종가/저가 vs N일 신저가선 (9·20일)', builtIn: true, period: 9 },
];
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
COMPOSITE_INDICATOR_ITEMS.map(i => ({
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
{
value: STOCH_OVERBOUGHT_PAIR_VALUE,
label: 'Stoch 과열×보조',
desc: stochPairPaletteDesc('CCI'),
builtIn: true,
secondaryIndicator: 'CCI',
},
...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 => ({
/** 팔레트 추가 모달 — 동일 지표 2기간 교차만 선택 (Stoch×보조는 내장 칩) */
export const COMPOSITE_TYPE_OPTIONS = COMPOSITE_INDICATOR_ITEMS.map(i => ({
value: i.value,
label: i.label,
}));
+4
View File
@@ -38,6 +38,10 @@ export interface LogicNode {
candleType?: string;
/** TIMEFRAME 노드 — 동일 조건을 여러 분봉 마감마다 평가 */
candleTypes?: string[];
/** Stoch 과열×보조지표 순차 OR 복합 — OR 루트에만 설정 */
stochPair?: {
secondaryIndicatorType: string;
};
}
export interface StrategyDSLDto {