실시간 차트 신고가,신저가 표시
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
type TradeSignalLabelItem,
|
||||
type TradeSignalMarkerWithPrice,
|
||||
} from './tradeSignalLabels';
|
||||
import { computePriceExtremeChartMarkers } from './priceExtremeChartEvents';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatLwcTimeWithPattern,
|
||||
@@ -274,6 +275,9 @@ export class ChartManager {
|
||||
private backtestMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||
/** 실시간 전략 체크 마커 */
|
||||
private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||
/** 9·20일 신고가·신저가 마커 */
|
||||
private priceExtremeMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||
private _priceExtremeLabelsVisible = false;
|
||||
private _tradeSignalLabelListeners = new Set<() => void>();
|
||||
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
||||
/** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */
|
||||
@@ -515,7 +519,7 @@ export class ChartManager {
|
||||
panesInit[0].setStretchFactor(1);
|
||||
}
|
||||
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._refreshPriceExtremeMarkers();
|
||||
|
||||
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
||||
@@ -550,7 +554,7 @@ export class ChartManager {
|
||||
} else {
|
||||
ts.fitContent();
|
||||
}
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,7 +567,7 @@ export class ChartManager {
|
||||
from: bars[startIdx].time as Time,
|
||||
to: to as Time,
|
||||
});
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
}
|
||||
|
||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||
@@ -1777,7 +1781,14 @@ export class ChartManager {
|
||||
color: m.color,
|
||||
text: '',
|
||||
}));
|
||||
const all = [...patternAll, ...backtestAll, ...liveAll];
|
||||
const priceExtremeAll = this.priceExtremeMarkers.map(m => ({
|
||||
time: m.time as Time,
|
||||
position: m.position,
|
||||
shape: m.shape,
|
||||
color: m.color,
|
||||
text: '',
|
||||
}));
|
||||
const all = [...patternAll, ...backtestAll, ...liveAll, ...priceExtremeAll];
|
||||
if (this.mainMarkersPlugin) {
|
||||
try {
|
||||
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
||||
@@ -1876,9 +1887,50 @@ export class ChartManager {
|
||||
return [
|
||||
...this.backtestMarkers.map(toTradeSignalLabelItem),
|
||||
...this.liveStrategyMarkers.map(toTradeSignalLabelItem),
|
||||
...this.priceExtremeMarkers.map(toTradeSignalLabelItem),
|
||||
];
|
||||
}
|
||||
|
||||
/** rawBars 기준 9·20일 신고가·신저가 마커·라벨 재계산 */
|
||||
private _refreshPriceExtremeMarkers(): void {
|
||||
if (!this._priceExtremeLabelsVisible) {
|
||||
if (this.priceExtremeMarkers.length === 0) return;
|
||||
this.priceExtremeMarkers = [];
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
return;
|
||||
}
|
||||
const next = computePriceExtremeChartMarkers(this.rawBars);
|
||||
if (this._samePriceExtremeMarkers(next, this.priceExtremeMarkers)) return;
|
||||
this.priceExtremeMarkers = next;
|
||||
this._reapplyAllPatternMarkers();
|
||||
this._notifyTradeSignalLabels();
|
||||
}
|
||||
|
||||
/** 실시간 차트 — 9·20일 신고가·신저가 라벨·마커 표시 */
|
||||
setPriceExtremeLabelsVisible(visible: boolean): void {
|
||||
if (this._priceExtremeLabelsVisible === visible) return;
|
||||
this._priceExtremeLabelsVisible = visible;
|
||||
this._refreshPriceExtremeMarkers();
|
||||
}
|
||||
|
||||
isPriceExtremeLabelsVisible(): boolean {
|
||||
return this._priceExtremeLabelsVisible;
|
||||
}
|
||||
|
||||
private _samePriceExtremeMarkers(
|
||||
a: TradeSignalMarkerWithPrice[],
|
||||
b: TradeSignalMarkerWithPrice[],
|
||||
): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].time !== b[i].time || a[i].text !== b[i].text || a[i].position !== b[i].position) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribeTradeSignalLabels(cb: () => void): () => void {
|
||||
this._tradeSignalLabelListeners.add(cb);
|
||||
return () => { this._tradeSignalLabelListeners.delete(cb); };
|
||||
@@ -2281,6 +2333,7 @@ export class ChartManager {
|
||||
|
||||
// 4) 보조지표 마지막 포인트 스로틀 갱신
|
||||
this._scheduleIndicatorLastUpdate();
|
||||
this._refreshPriceExtremeMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2621,6 +2674,7 @@ export class ChartManager {
|
||||
if (this._pendingIndUpdate) {
|
||||
this._scheduleIndicatorLastUpdate();
|
||||
}
|
||||
this._refreshPriceExtremeMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2652,6 +2706,7 @@ export class ChartManager {
|
||||
if (!this._indicatorLoadStale(loadGen)) {
|
||||
this._finalizeIndicatorPaneLayout();
|
||||
}
|
||||
this._refreshPriceExtremeMarkers();
|
||||
}
|
||||
|
||||
// ─── Alerts ──────────────────────────────────────────────────────────────
|
||||
@@ -2896,7 +2951,11 @@ export class ChartManager {
|
||||
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
|
||||
private readonly _viewportListeners = new Set<() => void>();
|
||||
private _viewportLwcHooked = false;
|
||||
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
||||
private _viewportNotifyQueued = false;
|
||||
private readonly _lwcViewportHandler = (): void => {
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
};
|
||||
|
||||
private _notifyViewport(): void {
|
||||
for (const cb of this._viewportListeners) {
|
||||
@@ -2904,6 +2963,18 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** LWC timeScale·좌표 변환 반영 후 오버레이 재그리기 (연속 패닝 중 cancel 로 redraw 누락 방지) */
|
||||
private _scheduleNotifyViewport(): void {
|
||||
if (this._viewportNotifyQueued) return;
|
||||
this._viewportNotifyQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this._viewportNotifyQueued = false;
|
||||
this._notifyViewport();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _hookViewportLwc(): void {
|
||||
if (this._viewportLwcHooked) return;
|
||||
this._viewportLwcHooked = true;
|
||||
@@ -2923,6 +2994,7 @@ export class ChartManager {
|
||||
try {
|
||||
this.chart.timeScale().setVisibleLogicalRange(range);
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
} catch { /* 데이터 로드 전 */ }
|
||||
}
|
||||
|
||||
@@ -2931,9 +3003,10 @@ export class ChartManager {
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */
|
||||
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝·스크롤 중) */
|
||||
notifyViewportChanged(): void {
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
}
|
||||
|
||||
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
||||
@@ -3590,7 +3663,10 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────────────────────
|
||||
fitContent(): void { this.chart.timeScale().fitContent(); }
|
||||
fitContent(): void {
|
||||
this.chart.timeScale().fitContent();
|
||||
this._scheduleNotifyViewport();
|
||||
}
|
||||
|
||||
/** 시간축 확대 (visible logical range 20% 축소) */
|
||||
zoomIn(): void {
|
||||
@@ -4167,6 +4243,8 @@ export class ChartManager {
|
||||
this._mainPriceVisibleRange = next;
|
||||
try {
|
||||
ps.setVisibleRange(next);
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
} catch { /* 범위 미설정 등 */ }
|
||||
}
|
||||
|
||||
@@ -4223,6 +4301,7 @@ export class ChartManager {
|
||||
try {
|
||||
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
||||
}
|
||||
|
||||
@@ -4262,7 +4341,10 @@ export class ChartManager {
|
||||
* 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
|
||||
* (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
|
||||
*/
|
||||
scrollToRealTime(): void { this.chart.timeScale().scrollToRealTime(); }
|
||||
scrollToRealTime(): void {
|
||||
this.chart.timeScale().scrollToRealTime();
|
||||
this._scheduleNotifyViewport();
|
||||
}
|
||||
|
||||
/** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
|
||||
zoomToXRange(x0: number, x1: number): void {
|
||||
@@ -4272,6 +4354,7 @@ export class ChartManager {
|
||||
try {
|
||||
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
||||
this._notifyViewport();
|
||||
this._scheduleNotifyViewport();
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
|
||||
@@ -4430,6 +4513,7 @@ export class ChartManager {
|
||||
|
||||
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
||||
await this._refreshAllIndicatorsData();
|
||||
this._refreshPriceExtremeMarkers();
|
||||
return newBars.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const STORAGE_KEY = 'gc_chartPriceExtremeLabels';
|
||||
|
||||
/** 실시간 차트 — 9·20일 신고가·신저가 라벨 표시 (기본 off) */
|
||||
export function loadChartPriceExtremeLabelsVisible(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChartPriceExtremeLabelsVisible(visible: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, visible ? '1' : '0');
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { OHLCVBar } from '../types';
|
||||
import { formatUpbitKrwPrice } from './safeFormat';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
||||
import type { TradeSignalMarkerWithPrice } from './tradeSignalLabels';
|
||||
|
||||
const CHART_PERIODS = [9, 20] as const;
|
||||
|
||||
/** 직전 N봉 고가 중 최고값 (현재 봉 제외) — index t 에서 max(high[t-N]..high[t-1]) */
|
||||
export function priorHighestHigh(bars: OHLCVBar[], index: number, period: number): number | null {
|
||||
if (index < period) return null;
|
||||
let max = -Infinity;
|
||||
for (let j = index - period; j < index; j++) {
|
||||
max = Math.max(max, bars[j].high);
|
||||
}
|
||||
return max === -Infinity ? null : max;
|
||||
}
|
||||
|
||||
/** 직전 N봉 저가 중 최저값 (현재 봉 제외) */
|
||||
export function priorLowestLow(bars: OHLCVBar[], index: number, period: number): number | null {
|
||||
if (index < period) return null;
|
||||
let min = Infinity;
|
||||
for (let j = index - period; j < index; j++) {
|
||||
min = Math.min(min, bars[j].low);
|
||||
}
|
||||
return min === Infinity ? null : min;
|
||||
}
|
||||
|
||||
/** 종가가 직전 N봉 최고가선을 상향 돌파(CROSS_UP) */
|
||||
export function isNewHighCrossUp(bars: OHLCVBar[], index: number, period: number): boolean {
|
||||
const thresh = priorHighestHigh(bars, index, period);
|
||||
if (thresh == null) return false;
|
||||
const close = bars[index].close;
|
||||
if (close <= thresh) return false;
|
||||
if (index === 0) return true;
|
||||
const prevThresh = priorHighestHigh(bars, index - 1, period);
|
||||
const prevClose = bars[index - 1].close;
|
||||
if (prevThresh == null) return true;
|
||||
return prevClose <= prevThresh;
|
||||
}
|
||||
|
||||
/** 종가가 직전 N봉 최저가선을 하향 이탈(CROSS_DOWN) */
|
||||
export function isNewLowCrossDown(bars: OHLCVBar[], index: number, period: number): boolean {
|
||||
const thresh = priorLowestLow(bars, index, period);
|
||||
if (thresh == null) return false;
|
||||
const close = bars[index].close;
|
||||
if (close >= thresh) return false;
|
||||
if (index === 0) return true;
|
||||
const prevThresh = priorLowestLow(bars, index - 1, period);
|
||||
const prevClose = bars[index - 1].close;
|
||||
if (prevThresh == null) return true;
|
||||
return prevClose >= prevThresh;
|
||||
}
|
||||
|
||||
/** 실시간·백테스트 차트 — 9·20일 신고가·신저가 마커·라벨 */
|
||||
export function computePriceExtremeChartMarkers(bars: OHLCVBar[]): TradeSignalMarkerWithPrice[] {
|
||||
const out: TradeSignalMarkerWithPrice[] = [];
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const bar = bars[i];
|
||||
for (const period of CHART_PERIODS) {
|
||||
if (isNewHighCrossUp(bars, i, period)) {
|
||||
out.push({
|
||||
time: bar.time,
|
||||
position: 'aboveBar',
|
||||
shape: 'arrowDown',
|
||||
color: TRADE_SELL_COLOR,
|
||||
text: `${period}일 신고가 : ${formatUpbitKrwPrice(bar.close)}`,
|
||||
price: bar.close,
|
||||
});
|
||||
}
|
||||
if (isNewLowCrossDown(bars, i, period)) {
|
||||
out.push({
|
||||
time: bar.time,
|
||||
position: 'belowBar',
|
||||
shape: 'arrowUp',
|
||||
color: TRADE_BUY_COLOR,
|
||||
text: `${period}일 신저가 : ${formatUpbitKrwPrice(bar.close)}`,
|
||||
price: bar.close,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 전략편집기 — 복합지표·횡보 팔레트 → 템플릿 목록 행
|
||||
*/
|
||||
import type { PaletteItem } from './strategyPaletteStorage';
|
||||
import type { SidewaysFilterPaletteEntry } from './sidewaysFilterPaletteStorage';
|
||||
import {
|
||||
isInflection33BuyPaletteValue,
|
||||
isInflection33SellPaletteValue,
|
||||
} from './inflection33Strategy';
|
||||
import {
|
||||
isNewHigh920BuyPaletteValue,
|
||||
isNewLow920SellPaletteValue,
|
||||
} from './priceExtremeBreakoutPair';
|
||||
import { STABLE_STRATEGY_PALETTE_ITEMS } from './stableStrategyPairs';
|
||||
|
||||
export type TemplatePaletteSource =
|
||||
| 'user'
|
||||
| 'builtin'
|
||||
| 'composite-palette'
|
||||
| 'sideways-palette';
|
||||
|
||||
export type TemplatePaletteBadge = '내 템플릿' | '복합지표' | '횡보지표';
|
||||
|
||||
export interface PaletteTemplateRow {
|
||||
key: string;
|
||||
source: 'composite-palette' | 'sideways-palette';
|
||||
label: string;
|
||||
description?: string;
|
||||
signal: 'buy' | 'sell';
|
||||
badge: TemplatePaletteBadge;
|
||||
paletteItem?: PaletteItem;
|
||||
sidewaysFilterId?: string;
|
||||
}
|
||||
|
||||
export function templateBadgeForSource(source: TemplatePaletteSource): TemplatePaletteBadge | null {
|
||||
switch (source) {
|
||||
case 'user': return '내 템플릿';
|
||||
case 'composite-palette': return '복합지표';
|
||||
case 'sideways-palette': return '횡보지표';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function inferCompositePaletteSignal(item: PaletteItem): 'buy' | 'sell' {
|
||||
if (isNewHigh920BuyPaletteValue(item.value) || isInflection33BuyPaletteValue(item.value)) {
|
||||
return 'buy';
|
||||
}
|
||||
if (isNewLow920SellPaletteValue(item.value) || isInflection33SellPaletteValue(item.value)) {
|
||||
return 'sell';
|
||||
}
|
||||
const stable = STABLE_STRATEGY_PALETTE_ITEMS.find(s => s.value === item.value);
|
||||
if (stable) return stable.mode;
|
||||
if (item.value.endsWith('_BUY')) return 'buy';
|
||||
if (item.value.endsWith('_SELL')) return 'sell';
|
||||
return 'buy';
|
||||
}
|
||||
|
||||
export function buildCompositePaletteTemplateRows(
|
||||
items: PaletteItem[],
|
||||
hiddenIds: ReadonlySet<string>,
|
||||
): PaletteTemplateRow[] {
|
||||
return items
|
||||
.filter(item => item.kind === 'composite')
|
||||
.filter(item => !hiddenIds.has(`composite-palette:${item.id}`))
|
||||
.map(item => ({
|
||||
key: `composite-palette:${item.id}`,
|
||||
source: 'composite-palette' as const,
|
||||
label: item.label,
|
||||
description: item.desc,
|
||||
signal: inferCompositePaletteSignal(item),
|
||||
badge: '복합지표' as const,
|
||||
paletteItem: item,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSidewaysPaletteTemplateRows(
|
||||
items: SidewaysFilterPaletteEntry[],
|
||||
hiddenIds: ReadonlySet<string>,
|
||||
): PaletteTemplateRow[] {
|
||||
return items
|
||||
.filter(item => !hiddenIds.has(`sideways-palette:${item.id}`))
|
||||
.map(item => ({
|
||||
key: `sideways-palette:${item.id}`,
|
||||
source: 'sideways-palette' as const,
|
||||
label: item.label,
|
||||
description: item.desc,
|
||||
signal: item.mode === 'exclude' ? 'sell' : 'buy',
|
||||
badge: '횡보지표' as const,
|
||||
sidewaysFilterId: item.id,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user