투자관리 차트탭 기능 수정

This commit is contained in:
Macbook
2026-05-31 16:31:25 +09:00
parent 1b7c39e11f
commit 78f00dbaa5
14 changed files with 935 additions and 111 deletions
+36 -34
View File
@@ -264,6 +264,9 @@ export class ChartManager {
/** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */
private _compactPaneLayout = false;
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
private _paneAreaRatio: { candle: number; aux: number } | null = null;
constructor(
container: HTMLElement,
theme: Theme,
@@ -2054,10 +2057,6 @@ export class ChartManager {
private _viewportLwcHooked = false;
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
/** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */
private _candleAxisPanActive = false;
private _candleAxisPanOffsetPx = 0;
private _notifyViewport(): void {
for (const cb of this._viewportListeners) {
try { cb(); } catch { /* ok */ }
@@ -2096,29 +2095,6 @@ export class ChartManager {
this._notifyViewport();
}
/** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */
endCandleAxisPan(): void {
if (!this._candleAxisPanActive) return;
this._candleAxisPanActive = false;
this._candleAxisPanOffsetPx = 0;
this._notifyViewport();
}
isCandleAxisPanActive(): boolean {
return this._candleAxisPanActive;
}
getCandleAxisPanOffsetPx(): number {
return this._candleAxisPanOffsetPx;
}
private _ensureCandleAxisPan(): void {
if (this._candleAxisPanActive) return;
this._candleAxisPanActive = true;
this._candleAxisPanOffsetPx = 0;
this._notifyViewport();
}
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
const span = lr.to - lr.from;
@@ -3040,9 +3016,6 @@ export class ChartManager {
options?: { allowVerticalPan?: boolean },
): void {
if (deltaX !== 0) {
this._ensureCandleAxisPan();
this._candleAxisPanOffsetPx += deltaX;
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (lr) {
@@ -3272,6 +3245,11 @@ export class ChartManager {
return this.rawBars.length;
}
/** 현재 차트에 로드된 캔들 스냅샷 (과거 추가 로드·백테스트 재계산용) */
getRawBars(): OHLCVBar[] {
return this.rawBars.slice();
}
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
countBarsBetweenTime(t0: number, t1: number): number {
const lo = Math.min(t0, t1);
@@ -3296,15 +3274,15 @@ export class ChartManager {
* 사용자가 보고 있던 구간이 유지되도록 합니다.
* - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
*/
async prependBars(olderBars: OHLCVBar[]): Promise<void> {
if (!this.mainSeries || olderBars.length === 0) return;
async prependBars(olderBars: OHLCVBar[]): Promise<number> {
if (!this.mainSeries || olderBars.length === 0) return 0;
const firstExistingTime = this.rawBars[0]?.time as number | undefined;
const newBars = firstExistingTime !== undefined
? olderBars.filter(b => (b.time as number) < firstExistingTime)
: olderBars;
if (newBars.length === 0) return;
if (newBars.length === 0) return 0;
// 현재 가시 논리 범위 저장 (prepend 후 복원용)
const logicalRange = this.chart.timeScale().getVisibleLogicalRange();
@@ -3349,6 +3327,7 @@ export class ChartManager {
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
await this._refreshAllIndicatorsData();
return newBars.length;
}
/** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
@@ -3628,6 +3607,21 @@ export class ChartManager {
*
* @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
*/
/**
* 캔들 영역 vs 보조지표 영역 높이 비율 (예: 보조 1개 → 2:1, 2~5개 → 1:1, 6+ → 1:2)
* auxWeight 가 0 이면 비율 해제.
*/
setPaneAreaRatio(candleWeight: number, auxWeight: number): void {
if (auxWeight <= 0) {
this._paneAreaRatio = null;
} else {
this._paneAreaRatio = {
candle: Math.max(0.1, candleWeight),
aux: Math.max(0.1, auxWeight),
};
}
}
private _volumeFrac(H: number): number {
if (!this._volumeVisible || this._candleOnlyLayout) return 0;
return Math.min(Math.max(60 / H, 0.04), 0.12);
@@ -3750,7 +3744,15 @@ export class ChartManager {
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const N = this._activeIndicatorPaneIndices().size;
const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H), H);
const volFrac = this._volumeFrac(H);
let mainFrac: number;
if (this._paneAreaRatio && N > 0) {
const total = this._paneAreaRatio.candle + this._paneAreaRatio.aux;
const candleGroupFrac = this._paneAreaRatio.candle / total;
mainFrac = Math.max(0.12, candleGroupFrac - volFrac);
} else {
mainFrac = this._paneHeightFractions(N, volFrac, H).mainFrac;
}
return this._applyPaneStretchFactors(mainFrac, availableHeight);
}
@@ -0,0 +1,209 @@
import type { OHLCVBar } from '../types';
import type { StrategyDto } from './backendApi';
import { extractVirtualConditions } from './virtualStrategyConditions';
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI',
MACD: 'MACD',
MA: 'SMA',
EMA: 'EMA',
BOLLINGER: 'BollingerBands',
STOCHASTIC: 'Stochastic',
WILLIAMS_R: 'WilliamsPercentRange',
CCI: 'CCI',
ADX: 'ADX',
DMI: 'DMI',
OBV: 'OBV',
TRIX: 'TRIX',
VOLUME_OSC: 'VolumeOscillator',
VR: 'VR',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
NEW_PSYCHOLOGICAL: 'NewPsychological',
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
ICHIMOKU: 'IchimokuCloud',
ATR: 'ATR',
MFI: 'MFI',
};
/** 메인 캔들 pane 오버레이 — 하단 스파크 pane 제외 */
const OVERLAY_DSL = new Set([
'MA', 'EMA', 'BOLLINGER', 'ICHIMOKU', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'DISPARITY',
]);
export interface OscillatorPaneSpec {
key: string;
registryType: string;
label: string;
period?: number;
}
export interface OscillatorPaneData {
spec: OscillatorPaneSpec;
values: number[];
refLines?: number[];
color: string;
}
const PANE_COLORS: Record<string, string> = {
RSI: '#a78bfa',
MACD: '#38bdf8',
CCI: '#fbbf24',
Stochastic: '#f472b6',
WilliamsPercentRange: '#c084fc',
ADX: '#34d399',
MFI: '#fb923c',
OBV: '#94a3b8',
TRIX: '#22d3ee',
VolumeOscillator: '#86efac',
VR: '#fcd34d',
Psychological: '#e879f9',
NewPsychological: '#e879f9',
InvestPsychological: '#e879f9',
};
function computeRsi(closes: number[], period = 14): number[] {
if (closes.length < period + 1) return [];
const out: number[] = [];
let avgGain = 0;
let avgLoss = 0;
for (let i = 1; i <= period; i++) {
const d = closes[i] - closes[i - 1];
if (d >= 0) avgGain += d; else avgLoss -= d;
}
avgGain /= period;
avgLoss /= period;
for (let i = period; i < closes.length; i++) {
const d = closes[i] - closes[i - 1];
const gain = d > 0 ? d : 0;
const loss = d < 0 ? -d : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
out.push(100 - 100 / (1 + rs));
}
return out;
}
function computeMacd(closes: number[]): number[] {
const ema = (arr: number[], p: number) => {
const k = 2 / (p + 1);
let v = arr[0];
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
};
const e12 = ema(closes, 12);
const e26 = ema(closes, 26);
return e12.map((v, i) => v - e26[i]);
}
function computeCci(bars: OHLCVBar[], period = 20): number[] {
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
const out: number[] = [];
for (let i = period; i < tp.length; i++) {
const slice = tp.slice(i - period, i);
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
out.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
}
return out;
}
function computeSeries(bars: OHLCVBar[], registryType: string, period?: number): number[] {
const closes = bars.map(b => b.close);
switch (registryType) {
case 'RSI':
return computeRsi(closes, period ?? 14);
case 'MACD':
return computeMacd(closes);
case 'CCI':
return computeCci(bars, period ?? 20);
default:
return computeRsi(closes, 14);
}
}
function refLinesFor(registryType: string): number[] | undefined {
if (registryType === 'RSI') return [30, 50, 70];
if (registryType === 'CCI') return [-100, 0, 100];
if (registryType === 'MACD') return [0];
return undefined;
}
export function collectStrategyOscillatorPanes(strategy: StrategyDto | undefined): OscillatorPaneSpec[] {
if (!strategy) return [];
const rows = extractVirtualConditions(strategy);
const seen = new Set<string>();
const out: OscillatorPaneSpec[] = [];
for (const row of rows) {
if (OVERLAY_DSL.has(row.indicatorType)) continue;
const registryType = DSL_TO_REGISTRY[row.indicatorType] ?? row.indicatorType;
if (OVERLAY_DSL.has(registryType) || registryType === 'SMA' || registryType === 'EMA'
|| registryType === 'BollingerBands' || registryType === 'IchimokuCloud') {
continue;
}
if (seen.has(registryType)) continue;
seen.add(registryType);
out.push({
key: registryType,
registryType,
label: formatIndicatorDisplayLabel(row.indicatorType),
period: undefined,
});
}
return out;
}
export function buildOscillatorPaneData(
bars: OHLCVBar[],
specs: OscillatorPaneSpec[],
): OscillatorPaneData[] {
if (bars.length < 20) return [];
const out: OscillatorPaneData[] = [];
for (const spec of specs) {
const values = computeSeries(bars, spec.registryType, spec.period).slice(-80);
if (values.length < 2) continue;
out.push({
spec,
values,
refLines: refLinesFor(spec.registryType),
color: PANE_COLORS[spec.registryType] ?? '#94a3b8',
});
}
return out;
}
export function defaultOscillatorPaneSpecs(): OscillatorPaneSpec[] {
return [
{ key: 'RSI', registryType: 'RSI', label: 'RSI (14)', period: 14 },
{ key: 'MACD', registryType: 'MACD', label: 'MACD (12, 26, 9)' },
];
}
/** StrategyOscillatorPanes 에 실제로 그려지는 보조 pane 개수 */
export function countAuxiliaryOscillatorPaneCount(strategy: StrategyDto | undefined): number {
const specs = collectStrategyOscillatorPanes(strategy);
return (specs.length > 0 ? specs : defaultOscillatorPaneSpecs()).length;
}
/** TradingChart 보조 pane(비오버레이 지표) 개수 */
export function countNonOverlayIndicatorPanes(
indicators: Array<{ type: string }>,
isOverlay: (type: string) => boolean,
): number {
return indicators.filter(ind => !isOverlay(ind.type)).length;
}
/**
* 캔들 vs 보조지표 영역 flex 비율
* - 보조 1개: 2:1
* - 보조 2~5개: 1:1
* - 보조 6개+: 1:2
*/
export function chartPaneFlexRatio(auxPaneCount: number): { candle: number; aux: number } {
const n = Math.max(0, auxPaneCount);
if (n === 0) return { candle: 1, aux: 0 };
if (n === 1) return { candle: 2, aux: 1 };
if (n <= 5) return { candle: 1, aux: 1 };
return { candle: 1, aux: 2 };
}
+10
View File
@@ -23,3 +23,13 @@ export function resolveVirtualTargetNames(
: getKoreanName(market);
return { koreanName: ko, englishName: en };
}
/** 투자대상·차트 종목 선택 — 한글명(영문명) */
export function formatVirtualTargetOptionLabel(
market: string,
koreanName?: string | null,
englishName?: string | null,
): string {
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(market, koreanName, englishName);
return `${ko}(${en})`;
}