추세선 전략추가

This commit is contained in:
Macbook
2026-06-25 02:45:39 +09:00
parent ee8cd50781
commit f9447ea52f
31 changed files with 1793 additions and 49 deletions
+36 -2
View File
@@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react';
import type { Theme } from '../types';
import {
loadBacktestSettings,
saveBacktestSettings,
saveLiveStrategySettings,
type BacktestSettingsDto,
type LiveStrategySettingsDto,
DEFAULT_BACKTEST_SETTINGS,
} from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import TrendLineSettingsFields from './TrendLineSettingsFields';
import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState,
} from './IndicatorMainDefaultsPanel';
@@ -1366,15 +1370,32 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
);
const [liveSaving, setLiveSaving] = useState(false);
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
const [trendLineCfg, setTrendLineCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [trendLineSaving, setTrendLineSaving] = useState(false);
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') {
setBacktestPositionMode(s.positionMode);
if (s) {
setTrendLineCfg(s);
if (s.positionMode === 'SIGNAL_ONLY' || s.positionMode === 'LONG_ONLY') {
setBacktestPositionMode(s.positionMode);
}
}
});
}, []);
const persistTrendLine = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
const next = { ...trendLineCfg, ...patch };
setTrendLineCfg(next);
setTrendLineSaving(true);
try {
const saved = await saveBacktestSettings(next);
if (saved) setTrendLineCfg(saved);
} finally {
setTrendLineSaving(false);
}
}, [trendLineCfg]);
useEffect(() => {
if (liveSettingsProp) setLiveSettings(liveSettingsProp);
}, [liveSettingsProp]);
@@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
)}
</SettingSection>
{/* ── 추세선 설정 ──────────────────────────────────────────────────── */}
<SettingSection title="추세선 설정">
<p className="stg-hint" style={{ marginBottom: 12 }}>
N봉 .
2·· , .
{trendLineSaving && <span style={{ marginLeft: 8, color: 'var(--text3)' }}> </span>}
</p>
<TrendLineSettingsFields
cfg={trendLineCfg}
onChange={patch => { void persistTrendLine(patch); }}
/>
</SettingSection>
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
<SettingSection title="리스크 관리" grid>
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
+38 -2
View File
@@ -48,6 +48,8 @@ import {
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
import {
BAND_PALETTE_CHIP_ITEMS,
@@ -319,7 +321,7 @@ export default function StrategyEditorPage({
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<SignalTab>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -1436,6 +1438,15 @@ export default function StrategyEditorPage({
scheduleStrategyPersist();
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
const applyTrendLine = useCallback((item: { indicatorType: string; leftField?: string }, lookback: number) => {
const newNode = buildTrendLineConditionNode(item, buySellTab(signalTab), DEF, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, DEF, handleEditorStateChange]);
const templateRows = useMemo(
() => buildStrategyTemplatePaletteRows({
def: DEF,
@@ -2487,6 +2498,16 @@ export default function StrategyEditorPage({
>
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
@@ -2524,7 +2545,7 @@ export default function StrategyEditorPage({
applyPaletteItem(item);
}}
/>
) : (
) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab
def={DEF}
items={sidewaysPalette}
@@ -2541,6 +2562,21 @@ export default function StrategyEditorPage({
applySidewaysFilter(item.id);
}}
/>
) : (
<TrendLinePaletteTab
signalType={buySellTab(signalTab)}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)}
</>
)}
@@ -0,0 +1,103 @@
/**
* 설정 화면 — 전략 설정 · 추세선 조건
*/
import React from 'react';
import type { BacktestSettingsDto } from '../utils/backendApi';
export const TREND_LINE_MODE_OPTIONS = [
{
value: 'SWING' as const,
label: '스윙 피벗 (권장)',
desc: 'N봉 내 스윙 고/저점 2개 — 상승=저점↑·하락=고점↓',
},
{
value: 'TWO_POINT' as const,
label: '2점 폴백',
desc: '피벗 미충족 시 구간 양端(Low/High) 직선',
},
{
value: 'REGRESSION' as const,
label: '회귀 폴백',
desc: '피벗 미충족 시 N봉 Low/High OLS',
},
];
interface Props {
cfg: BacktestSettingsDto;
onChange: (patch: Partial<BacktestSettingsDto>) => void;
}
export const TrendLineSettingsFields: React.FC<Props> = ({ cfg, onChange }) => {
const mode = cfg.trendLineMode ?? 'SWING';
return (
<>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> </span>
<span className="stg-row-desc">
( )= ( ) · ( )= ( ).
Low/High, .
</span>
</div>
<select
className="stg-select stg-select--wide"
value={mode}
onChange={e => onChange({ trendLineMode: e.target.value as BacktestSettingsDto['trendLineMode'] })}
>
{TREND_LINE_MODE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> lookback ()</span>
<span className="stg-row-desc">
[iN, i1] i . 30~50· 100~120 .
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={2}
max={500}
value={cfg.trendLineDefaultLookback ?? 10}
onChange={e => onChange({ trendLineDefaultLookback: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc">
판정: 해당 Low/High가 · k봉보다
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingPrecedingBars ?? 1}
onChange={e => onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc"> (k)</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingFollowingBars ?? 1}
onChange={e => onChange({ trendLineSwingFollowingBars: Number(e.target.value) })}
/>
</div>
</>
);
};
export default TrendLineSettingsFields;
@@ -31,6 +31,11 @@ interface Props {
stableStrategy?: boolean;
ichimokuSituation?: boolean;
secondaryIndicator?: string;
/** 추세선 돌파 — 드래그 payload type: trendLine */
trendLine?: boolean;
trendLineIndicatorType?: string;
trendLineLeftField?: string;
trendLineLookback?: number;
selected?: boolean;
onSelect?: () => void;
onAdd: () => void;
@@ -38,10 +43,23 @@ interface Props {
export default function PaletteChip({
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator, selected = false, onSelect, onAdd,
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator,
trendLine = false, trendLineIndicatorType, trendLineLeftField, trendLineLookback,
selected = false, onSelect, onAdd,
}: Props) {
const buildPayload = (): PaletteDragPayload => ({
type, value, label,
const buildPayload = (): PaletteDragPayload => {
if (trendLine) {
return {
type: 'trendLine',
value,
label,
indicatorType: trendLineIndicatorType,
leftField: trendLineLeftField,
lookback: trendLineLookback,
};
}
return {
type, value, label,
composite: composite || undefined,
stochPair: stochPair || undefined,
priceExtremeBreakout: priceExtremeBreakout || undefined,
@@ -53,7 +71,8 @@ export default function PaletteChip({
period: periodValue,
shortPeriod,
longPeriod,
});
};
};
const pointerMode = needsPointerPaletteDrag();
@@ -31,6 +31,7 @@ import {
type DefType,
} from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import {
isPaletteHtmlDrag,
isPaletteHtmlDragActive,
@@ -608,11 +609,14 @@ function StrategyEditorCanvasInner({
}, [setNodes]);
const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
): LogicNode | null => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
}
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
@@ -36,6 +36,7 @@ import LogicGateOpToggle from './LogicGateOpToggle';
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import {
clearPaletteDropHighlights,
findPaletteDropKeyAtPoint,
@@ -403,11 +404,14 @@ export default function StrategyListEditor({
}, [onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
) => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
}
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
@@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import PaletteChip from './PaletteChip';
import { loadBacktestSettings } from '../../utils/backendApi';
import {
filterTrendLinePaletteItems,
type TrendLinePaletteItem,
} from '../../utils/trendLinePalette';
interface Props {
signalType: 'buy' | 'sell';
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: TrendLinePaletteItem, lookback: number) => void;
}
function trendLinePeriodLabel(lookback: number, signalType: 'buy' | 'sell'): string {
const dir = signalType === 'buy' ? '상향' : '하향';
return `${lookback}봉 · ${dir}`;
}
export default function TrendLinePaletteTab({
signalType,
searchQuery,
selectedItemId,
onSelectItem,
onAddToCanvas,
}: Props) {
const [defaultLookback, setDefaultLookback] = useState(10);
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.trendLineDefaultLookback != null && s.trendLineDefaultLookback > 1) {
setDefaultLookback(s.trendLineDefaultLookback);
}
});
}, []);
const items = useMemo(
() => filterTrendLinePaletteItems(searchQuery),
[searchQuery],
);
return (
<div className="se-palette-section se-palette-section--trendline se-palette-section--scroll">
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title"> </span>
<span className="se-palette-toolbar-hint">
{signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'}
</span>
</div>
<div className="se-palette-grid se-palette-grid--3">
{items.map(item => (
<PaletteChip
key={item.id}
type="indicator"
value={item.id}
label={item.label}
desc={item.desc}
color="trendline"
trendLine
trendLineIndicatorType={item.indicatorType}
trendLineLeftField={item.leftField}
trendLineLookback={defaultLookback}
period={trendLinePeriodLabel(defaultLookback, signalType)}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item, defaultLookback)}
/>
))}
</div>
{items.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
import TradingChart from '../TradingChart';
import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal';
import { MarketSearchPanel } from '../MarketSearchPanel';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe, Drawing } from '../../types';
import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings';
@@ -14,8 +14,10 @@ import {
import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
import type { BacktestSettingsDto, BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals';
import { buildSignalTrendLineDrawings } from '../../utils/buildSignalTrendLineDrawings';
import { setIndicatorChartContext } from '../../utils/indicatorRegistry';
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
import {
BACKTEST_DISPLAY_BAR_COUNT,
@@ -197,6 +199,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [evalSettings, setEvalSettings] = useState<BacktestSettingsDto | null>(null);
const [signalDrawings, setSignalDrawings] = useState<Drawing[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null);
const [magnifierActive, setMagnifierActive] = useState(false);
@@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
setBacktestRunning(true);
setBacktestError(null);
try {
const { signals: nextSignals } = await fetchStrategyEvaluationSignals({
const result = await fetchStrategyEvaluationSignals({
strategyId: strategy.id,
strategy,
market,
@@ -426,7 +430,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
useDraftDsl,
});
if (gen !== backtestGenRef.current) return;
setSignals(nextSignals);
setEvalSettings(result.settings);
setSignals(result.signals);
applyMarkers();
} catch (e) {
if (gen !== backtestGenRef.current) return;
@@ -461,6 +466,29 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onSignalsChange?.(signals);
}, [signals, onSignalsChange]);
useEffect(() => {
setIndicatorChartContext(market, timeframe);
if (!strategy || bars.length === 0) {
setSignalDrawings([]);
return;
}
if (selectedBarIndex < 0 || selectedBarIndex >= bars.length) {
setSignalDrawings([]);
return;
}
let cancelled = false;
void buildSignalTrendLineDrawings({
bars,
barIndex: selectedBarIndex,
strategy,
settings: evalSettings,
getParams,
}).then(drawings => {
if (!cancelled) setSignalDrawings(drawings);
});
return () => { cancelled = true; };
}, [bars, strategy, evalSettings, getParams, market, timeframe, selectedBarIndex]);
useEffect(() => {
onBacktestRunningChange?.(backtestRunning);
}, [backtestRunning, onBacktestRunningChange]);
@@ -972,7 +1000,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
mode="chart"
indicators={indicators}
drawingTool="cursor"
drawings={[]}
drawings={signalDrawings}
logScale={false}
onCrosshair={() => {}}
onManagerReady={onManagerReady}
@@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react';
import PaletteChip from '../strategyEditor/PaletteChip';
import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab';
import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from '../strategyEditor/TrendLinePaletteTab';
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
import {
BAND_PALETTE_CHIP_ITEMS,
@@ -17,7 +18,7 @@ import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluat
type EditorApi = Pick<
ReturnType<typeof useStrategyEvaluationEditor>,
'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter'
'def' | 'signalTab' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' | 'applyTrendLine'
>;
interface Props {
@@ -35,10 +36,10 @@ function paletteKey(type: string, id: string) {
}
const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => {
const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor;
const { def, signalTab, applyPalette, applyPaletteItem, applySidewaysFilter, applyTrendLine } = editor;
const [paletteSearch, setPaletteSearch] = useState('');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
>
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
@@ -178,7 +189,7 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applyPaletteItem(item);
}}
/>
) : (
) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab
def={def as DefType}
items={sidewaysPalette}
@@ -195,6 +206,21 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applySidewaysFilter(item.id);
}}
/>
) : (
<TrendLinePaletteTab
signalType={signalTab}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)}
</div>
);
@@ -59,6 +59,7 @@ import {
buildSidewaysFilterNode,
loadSidewaysPaletteItems,
} from '../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IndicatorVisualConfig } from './useIndicatorSettings';
import type { PaletteItem } from '../utils/strategyPaletteStorage';
@@ -490,6 +491,18 @@ export function useStrategyEvaluationEditor({
});
}, [def, handleEditorStateChange]);
const applyTrendLine = useCallback((
item: { indicatorType: string; leftField?: string },
lookback: number,
) => {
const newNode = buildTrendLineConditionNode(item, signalTab, def, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, def, handleEditorStateChange]);
const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => {
const payload = buildTemplateEvaluationPayload(row, def);
if (!payload) {
@@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({
applyPalette,
applyPaletteItem,
applySidewaysFilter,
applyTrendLine,
applyTemplateForEvaluation,
saving,
saveNow,
+37
View File
@@ -1961,6 +1961,15 @@
color: var(--se-palette-section-range, #f59e0b);
box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b);
}
.se-palette-subtab--trendline.se-palette-subtab--on {
color: var(--se-palette-section-trendline, #38bdf8);
box-shadow: inset 0 -2px 0 var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--trendline h3,
.se-palette-section--trendline .se-palette-toolbar-title {
color: var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--range .se-palette-toolbar-title {
color: var(--se-palette-section-range, #f59e0b);
@@ -2471,6 +2480,31 @@ body.se-palette-drag-armed .se-palette-section--scroll {
0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
}
/* ── 추세선 돌파 ── */
.se-palette-card--trendline {
--se-palette-trendline-accent: var(--se-palette-section-trendline, #38bdf8);
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 42%, transparent);
background: color-mix(in srgb, var(--se-palette-trendline-accent) 10%, var(--bg2));
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
--se-palette-chip-caption: color-mix(in srgb, var(--se-palette-trendline-accent) 34%, #fff 66%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-palette-trendline-accent) 18%, #fff 82%);
--se-palette-chip-period-bg: color-mix(in srgb, #000 34%, transparent);
--se-palette-chip-period-border: color-mix(in srgb, var(--se-palette-trendline-accent) 40%, transparent);
}
.se-palette-card--trendline .se-palette-card-icon {
background: color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
color: var(--se-palette-trendline-accent);
}
.se-palette-card--trendline:hover {
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 65%, transparent);
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
0 4px 14px color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
}
/* ── 복합지표 ── */
.se-palette-card--composite {
border-color: color-mix(in srgb, #c084fc 42%, transparent);
@@ -2581,6 +2615,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */
.se-palette-panel .se-palette-card--band .se-palette-card-name,
.se-palette-panel .se-palette-card--ind .se-palette-card-name,
.se-palette-panel .se-palette-card--trendline .se-palette-card-name,
.se-palette-panel .se-palette-card--composite .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name,
@@ -2592,6 +2627,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 설명·기간 보조 텍스트 가독성 */
.se-palette-panel .se-palette-card--band .se-palette-card-desc,
.se-palette-panel .se-palette-card--ind .se-palette-card-desc,
.se-palette-panel .se-palette-card--trendline .se-palette-card-desc,
.se-palette-panel .se-palette-card--composite .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc,
@@ -2607,6 +2643,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
.se-palette-panel .se-palette-card--band .se-palette-card-period,
.se-palette-panel .se-palette-card--ind .se-palette-card-period,
.se-palette-panel .se-palette-card--trendline .se-palette-card-period,
.se-palette-panel .se-palette-card--composite .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period,
@@ -77,6 +77,7 @@
--se-palette-section-band: #a78bfa;
--se-palette-section-ind: #34d399;
--se-palette-section-range: #f59e0b;
--se-palette-section-trendline: #38bdf8;
--se-palette-strategy-name: #f59e0b;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%);
@@ -221,6 +222,7 @@
--se-palette-section-band: #7b1fa2;
--se-palette-section-ind: #2e7d32;
--se-palette-section-range: #e65100;
--se-palette-section-trendline: #0288d1;
--se-palette-strategy-name: #e65100;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%);
@@ -290,6 +292,7 @@
--se-palette-section-band: #ce93d8;
--se-palette-section-ind: #69f0ae;
--se-palette-section-range: #ffb74d;
--se-palette-section-trendline: #4fc3f7;
--se-palette-strategy-name: #ffb74d;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%);
+12
View File
@@ -1440,6 +1440,14 @@ export interface BacktestSettingsDto {
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
/** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */
tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE';
/** 추세선 조건 — TWO_POINT | REGRESSION | SWING */
trendLineMode?: 'TWO_POINT' | 'REGRESSION' | 'SWING';
/** 조건 lookbackPeriod 미지정 시 기본 N봉 */
trendLineDefaultLookback?: number;
/** SWING 모드 — 스윙 저점 판정 좌측 봉 수 */
trendLineSwingPrecedingBars?: number;
/** SWING 모드 — 스윙 저점 판정 우측 봉 수 */
trendLineSwingFollowingBars?: number;
}
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
@@ -1465,6 +1473,10 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
positionMode: 'LONG_ONLY',
analysisMethod: 'MARK_TO_MARKET',
tradeExecutionMode: 'BACKTEST_ENGINE',
trendLineMode: 'TWO_POINT',
trendLineDefaultLookback: 10,
trendLineSwingPrecedingBars: 1,
trendLineSwingFollowingBars: 1,
};
/** 백테스팅 설정 로드 */
@@ -0,0 +1,154 @@
/**
* 전략평가 차트 — 선택 봉 기준 N봉 추세선 Drawing 생성
*/
import type { Drawing } from '../types';
import type { OHLCVBar } from '../types';
import type { BacktestSettingsDto, StrategyDto } from './backendApi';
import type { ConditionDSL, LogicNode } from './strategyTypes';
import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry';
import { calculateIndicator } from './indicatorRegistry';
import { DSL_TO_REGISTRY } from './strategyToChartIndicators';
function walkTrendLineConditions(
node: LogicNode | null | undefined,
side: 'buy' | 'sell',
out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if (node.type === 'NOT') {
const child = node.children?.[0];
if (child) walkTrendLineConditions(child, side, out);
return;
}
if (node.type === 'AND' || node.type === 'OR') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if ((!node.type || node.type === 'CONDITION') && node.condition) {
const ct = node.condition.conditionType;
if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') {
out.push({ side, cond: node.condition });
}
}
}
function registryType(dslType: string): string {
return DSL_TO_REGISTRY[dslType] ?? dslType;
}
function isPriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_');
}
async function resolveSourceValues(
bars: OHLCVBar[],
cond: ConditionDSL,
getParams: (type: string) => Record<string, number | string | boolean>,
): Promise<number[] | null> {
const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase();
if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') {
return bars.map(b => b.close);
}
if (lf === 'HIGH_PRICE') return bars.map(b => b.high);
if (lf === 'LOW_PRICE') return bars.map(b => b.low);
if (lf === 'OPEN_PRICE') return bars.map(b => b.open);
const regType = registryType(cond.indicatorType);
try {
const params = { ...getParams(regType), ...(cond.params ?? {}) };
const { plots } = await calculateIndicator(regType, bars, params);
const plotKeys = Object.keys(plots);
if (plotKeys.length === 0) return null;
let plotKey = plotKeys[0]!;
if (lf.includes('K') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1';
else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2';
else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1';
const plot = plots[plotKey as keyof typeof plots];
if (!plot || !Array.isArray(plot)) return null;
const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value]));
return bars.map(b => {
const v = byTime.get(b.time);
return v != null && Number.isFinite(v) ? v : NaN;
});
} catch {
return bars.map(b => b.close);
}
}
/** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */
export async function buildSignalTrendLineDrawings(opts: {
bars: OHLCVBar[];
/** 캔들 선택봉 인덱스 */
barIndex: number;
strategy: StrategyDto | null;
settings?: BacktestSettingsDto | null;
getParams: (type: string) => Record<string, number | string | boolean>;
}): Promise<Drawing[]> {
const { bars, barIndex, strategy, settings, getParams } = opts;
if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return [];
const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = [];
walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions);
walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions);
if (conditions.length === 0) return [];
const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField));
if (priceConds.length === 0) return [];
const config = trendLineConfigFromSettings(settings);
const barTimes = bars.map(b => b.time);
const drawings: Drawing[] = [];
const sourceCache = new Map<string, number[]>();
for (const { cond } of priceConds) {
const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
if (!sourceCache.has(key)) {
const values = await resolveSourceValues(bars, cond, getParams);
if (values) sourceCache.set(key, values);
}
}
for (const { side, cond } of priceConds) {
const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
const sourceValues = sourceCache.get(cacheKey);
if (!sourceValues) continue;
const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback);
const kind = trendLineKindForCross(cond.conditionType, side);
const priceScale = isTrendLinePriceScaleField(cond.leftField);
const seg = trendLineSegmentAt(
barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale,
);
if (!seg) continue;
const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`;
drawings.push({
id,
type: 'trendline',
points: [
{ time: seg.startTimeSec, price: seg.startPrice },
{ time: seg.endTimeSec, price: seg.endPrice },
],
color: side === 'buy' ? '#26a69a' : '#ef5350',
lineWidth: 2,
style: 'dashed',
visible: true,
});
}
return drawings;
}
+15 -2
View File
@@ -418,6 +418,8 @@ const COND_OPTIONS = [
{ v: 'NEQ', l: '다름 (!=)' },
{ v: 'CROSS_UP', l: '상향 돌파' },
{ v: 'CROSS_DOWN', l: '하향 돌파' },
{ v: 'TREND_LINE_CROSS_UP', l: '추세선 상향 돌파' },
{ v: 'TREND_LINE_CROSS_DOWN', l: '추세선 하향 돌파' },
{ v: 'SLOPE_UP', l: '상승 기울기' },
{ v: 'SLOPE_DOWN', l: '하락 기울기' },
{ v: 'DIFF_GT', l: '차이 > 값' },
@@ -444,6 +446,7 @@ export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => {
const COMMON_COND_KEYS = [
'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN',
'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN',
'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS',
];
@@ -899,6 +902,10 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D
if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE';
updated.slopePeriod = updated.slopePeriod ?? 3;
} else if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE';
updated.lookbackPeriod = updated.lookbackPeriod ?? 10;
} else if (newCondType === 'HOLD_N_DAYS') {
updated.leftField = 'NONE'; updated.rightField = 'NONE';
updated.holdDays = updated.holdDays ?? 3;
@@ -1004,6 +1011,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`;
}
if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(c.conditionType)) {
const n = c.lookbackPeriod ?? 10;
const dir = c.conditionType === 'TREND_LINE_CROSS_UP' ? '상향 돌파' : '하향 돌파';
return `${indName} - 이전 ${n}봉 추세선 ${dir} · ${L}`;
}
const rangeClause = formatCandleRangeClause(c);
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
return `${indName} - ${rangeClause}${L} ${C}`;
@@ -1215,11 +1227,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({
};
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isTrendLineType = ['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType);
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType) || isTrendLineType;
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const rightValue = isSlopeType || isTrendLineType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => {
@@ -25,7 +25,7 @@ import {
type StrategyIndicatorRef,
} from './strategyIndicatorSync';
const DSL_TO_REGISTRY: Record<string, string> = {
export const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
MACD: 'MACD',
MA: 'SMA',
+2 -1
View File
@@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record<string, string> = {
GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)',
EQ: '같음(=)', NEQ: '다름(≠)',
CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파',
TREND_LINE_CROSS_UP: '추세선 상향 돌파', TREND_LINE_CROSS_DOWN: '추세선 하향 돌파',
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
DIFF_GT: '차이>값', DIFF_LT: '차이<값',
HOLD_N_DAYS: 'N일 유지',
@@ -118,7 +119,7 @@ export const CONDITION_LABEL: Record<string, string> = {
VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율',
};
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE'];
const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS];
+305
View File
@@ -0,0 +1,305 @@
/**
* N봉 추세선 — backend TrendLineMath 와 동기 (스윙 피벗).
* fit [anchorN, anchor1], 상승(UP)=스윙 저점·하락(DOWN)=스윙 고점, anchor까지 연장.
*/
import type { BacktestSettingsDto } from './backendApi';
export type TrendLineMode = 'TWO_POINT' | 'REGRESSION' | 'SWING';
export type TrendLineKind = 'UP' | 'DOWN';
export interface TrendLineConfig {
mode: TrendLineMode;
defaultLookback: number;
swingPrecedingBars: number;
swingFollowingBars: number;
}
export interface TrendLineSegment {
startTimeSec: number;
startPrice: number;
endTimeSec: number;
endPrice: number;
}
interface PivotPair {
indexA: number;
yA: number;
indexB: number;
yB: number;
}
export function trendLineKindForCross(conditionType?: string, side?: 'buy' | 'sell'): TrendLineKind {
if (conditionType === 'TREND_LINE_CROSS_UP') return 'DOWN';
if (conditionType === 'TREND_LINE_CROSS_DOWN') return 'UP';
return side === 'buy' ? 'DOWN' : 'UP';
}
export function isTrendLinePriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_') || f.includes('VWAP')
|| f.includes('CONVERSION') || f.includes('BASE') || f.includes('SPAN')
|| f.includes('LAGGING') || f.includes('CLOUD');
}
export function trendLineConfigFromSettings(
settings?: Pick<
BacktestSettingsDto,
| 'trendLineMode'
| 'trendLineDefaultLookback'
| 'trendLineSwingPrecedingBars'
| 'trendLineSwingFollowingBars'
> | null,
): TrendLineConfig {
let mode = settings?.trendLineMode ?? 'SWING';
if (mode !== 'TWO_POINT' && mode !== 'REGRESSION' && mode !== 'SWING') {
mode = 'SWING';
}
const defaultLookback =
settings?.trendLineDefaultLookback != null && settings.trendLineDefaultLookback > 1
? settings.trendLineDefaultLookback
: 10;
return {
mode,
defaultLookback,
swingPrecedingBars: settings?.trendLineSwingPrecedingBars ?? 1,
swingFollowingBars: settings?.trendLineSwingFollowingBars ?? 1,
};
}
function numAt(values: number[], index: number): number | null {
if (index < 0 || index >= values.length) return null;
const v = values[index];
return Number.isFinite(v) ? v : null;
}
function yAtPivot(
bars: { low: number; high: number }[],
sourceValues: number[],
index: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (priceScale) {
return kind === 'UP' ? bars[index]!.low : bars[index]!.high;
}
return numAt(sourceValues, index) ?? NaN;
}
function isSwingLow(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const low = yAtPivot(bars, sourceValues, i, 'UP', priceScale);
if (!Number.isFinite(low)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'UP', priceScale);
if (!Number.isFinite(v) || v < low) return false;
}
return true;
}
function isSwingHigh(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const high = yAtPivot(bars, sourceValues, i, 'DOWN', priceScale);
if (!Number.isFinite(high)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'DOWN', priceScale);
if (!Number.isFinite(v) || v > high) return false;
}
return true;
}
function collectSwingIndices(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
kLeft: number,
kRight: number,
kind: TrendLineKind,
priceScale: boolean,
): number[] {
const out: number[] = [];
for (let i = start + kLeft; i <= end - kRight; i++) {
const isSwing = kind === 'UP'
? isSwingLow(bars, sourceValues, i, kLeft, kRight, priceScale)
: isSwingHigh(bars, sourceValues, i, kLeft, kRight, priceScale);
if (isSwing) out.push(i);
}
return out;
}
function resolvePivots(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): PivotPair | null {
const kLeft = Math.max(0, config.swingPrecedingBars);
const kRight = Math.max(0, config.swingFollowingBars);
const swings = collectSwingIndices(bars, sourceValues, start, end, kLeft, kRight, kind, priceScale);
if (swings.length < 2) return null;
const swingA = swings[0]!;
for (let j = 1; j < swings.length; j++) {
const swingB = swings[j]!;
if (swingB <= swingA) continue;
const yA = yAtPivot(bars, sourceValues, swingA, kind, priceScale);
const yB = yAtPivot(bars, sourceValues, swingB, kind, priceScale);
if (!Number.isFinite(yA) || !Number.isFinite(yB)) continue;
if (kind === 'UP' && yB > yA) return { indexA: swingA, yA, indexB: swingB, yB };
if (kind === 'DOWN' && yB < yA) return { indexA: swingA, yA, indexB: swingB, yB };
}
return null;
}
function lineAt(p: PivotPair, evalIndex: number): number {
if (p.indexB === p.indexA) return p.yA;
const t = (evalIndex - p.indexA) / (p.indexB - p.indexA);
return p.yA + (p.yB - p.yA) * t;
}
function twoPointAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const y1 = yAtPivot(bars, sourceValues, end, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(y1) || end === start) return NaN;
const t = (evalIndex - start) / (end - start);
return y0 + (y1 - y0) * t;
}
function regressionAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
let count = 0;
for (let i = start; i <= end; i++) {
const y = yAtPivot(bars, sourceValues, i, kind, priceScale);
if (!Number.isFinite(y)) continue;
const x = i - start;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
count++;
}
if (count < 2) return NaN;
const denom = count * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-12) return NaN;
const b = (count * sumXY - sumX * sumY) / denom;
const a = (sumY - b * sumX) / count;
return a + b * (evalIndex - start);
}
function fallbackValueAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (config.mode === 'SWING') return NaN;
if (config.mode === 'REGRESSION') {
return regressionAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
return twoPointAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
export function trendLineValueAtEval(
sourceValues: number[],
bars: { low: number; high: number }[],
anchorIndex: number,
evalIndex: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (anchorIndex < 1 || lookback < 2) return NaN;
const start = anchorIndex - lookback;
const end = anchorIndex - 1;
if (start < 0 || end < start) return NaN;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) return lineAt(pivots, evalIndex);
return fallbackValueAt(bars, sourceValues, start, end, evalIndex, config, kind, priceScale);
}
export function trendLineSegmentAt(
barTimes: number[],
sourceValues: number[],
bars: { low: number; high: number }[],
index: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): TrendLineSegment | null {
if (index < 1 || lookback < 2 || sourceValues.length !== barTimes.length) return null;
const start = index - lookback;
const end = index - 1;
if (start < 0 || end < start) return null;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) {
const yAt = lineAt(pivots, index);
if (!Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[pivots.indexA]!,
startPrice: pivots.yA,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
if (config.mode === 'SWING') return null;
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const yAt = fallbackValueAt(bars, sourceValues, start, end, index, config, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[start]!,
startPrice: y0,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 전략편집기 — 추세선 돌파 조건 팔레트
*/
import type { LogicNode } from './strategyTypes';
import {
applyCondTypeDefaults,
genId,
getDefaultConditionFields,
type DefType,
} from './strategyEditorShared';
import { initConditionPeriodsInherit } from './conditionPeriods';
export interface TrendLinePaletteItem {
id: string;
indicatorType: string;
label: string;
desc: string;
leftField?: string;
}
export const TREND_LINE_PALETTE_ITEMS: TrendLinePaletteItem[] = [
{ id: 'tl-close', indicatorType: 'MA', label: '종가', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-ma', indicatorType: 'MA', label: 'MA', desc: '이동평균 vs N봉 추세선', leftField: 'MA20' },
{ id: 'tl-ema', indicatorType: 'EMA', label: 'EMA', desc: '지수이동평균 vs N봉 추세선', leftField: 'EMA20' },
{ id: 'tl-boll', indicatorType: 'BOLLINGER', label: '볼린저', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-rsi', indicatorType: 'RSI', label: 'RSI', desc: 'RSI vs N봉 추세선' },
{ id: 'tl-macd', indicatorType: 'MACD', label: 'MACD', desc: 'MACD선 vs N봉 추세선', leftField: 'MACD_LINE' },
{ id: 'tl-stoch', indicatorType: 'STOCHASTIC', label: 'Stochastic', desc: '%K vs N봉 추세선', leftField: 'K' },
{ id: 'tl-cci', indicatorType: 'CCI', label: 'CCI', desc: 'CCI vs N봉 추세선' },
{ id: 'tl-adx', indicatorType: 'ADX', label: 'ADX', desc: 'ADX vs N봉 추세선' },
{ id: 'tl-obv', indicatorType: 'OBV', label: 'OBV', desc: 'OBV vs N봉 추세선' },
];
export function findTrendLinePaletteItem(id: string): TrendLinePaletteItem | undefined {
return TREND_LINE_PALETTE_ITEMS.find(i => i.id === id);
}
export function filterTrendLinePaletteItems(query: string): TrendLinePaletteItem[] {
const q = query.trim().toLowerCase();
if (!q) return TREND_LINE_PALETTE_ITEMS;
return TREND_LINE_PALETTE_ITEMS.filter(
i => i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.indicatorType.toLowerCase().includes(q),
);
}
export function buildTrendLineConditionNode(
item: Pick<TrendLinePaletteItem, 'indicatorType' | 'leftField'>,
signalType: 'buy' | 'sell',
def: DefType,
lookback = 10,
): LogicNode {
const defaults = getDefaultConditionFields(item.indicatorType, signalType, def);
const condType = signalType === 'buy' ? 'TREND_LINE_CROSS_UP' : 'TREND_LINE_CROSS_DOWN';
let condition = applyCondTypeDefaults(
{
indicatorType: item.indicatorType,
conditionType: condType,
leftField: item.leftField ?? defaults.l,
rightField: 'NONE',
lookbackPeriod: Math.max(2, lookback),
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
},
condType,
def,
);
condition = initConditionPeriodsInherit(item.indicatorType, condition, def);
return { id: genId(), type: 'CONDITION', condition };
}
export function buildTrendLineConditionNodeFromDrag(data: {
value: string;
indicatorType?: string;
leftField?: string;
lookback?: number;
}, signalType: 'buy' | 'sell', def: DefType): LogicNode | null {
const item = findTrendLinePaletteItem(data.value);
const indicatorType = item?.indicatorType ?? data.indicatorType;
if (!indicatorType) return null;
return buildTrendLineConditionNode(
{ indicatorType, leftField: item?.leftField ?? data.leftField },
signalType,
def,
data.lookback ?? 10,
);
}