가상투자 메뉴 기능 구현
This commit is contained in:
@@ -1066,3 +1066,56 @@ export async function saveLiveStrategySettingsBulk(
|
||||
})) ?? [];
|
||||
return list;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 가상투자 — 실시간 조건 충족 현황 (Ta4j Rule.isSatisfied)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LiveConditionRowDto {
|
||||
id: string;
|
||||
indicatorType: string;
|
||||
displayName: string;
|
||||
conditionType: string;
|
||||
conditionLabel: string;
|
||||
thresholdLabel: string;
|
||||
currentValue: number | null;
|
||||
targetValue: number | null;
|
||||
satisfied: boolean | null;
|
||||
timeframe: string;
|
||||
side: 'buy' | 'sell';
|
||||
}
|
||||
|
||||
export interface LiveConditionStatusDto {
|
||||
market: string;
|
||||
strategyId: number;
|
||||
timeframe: string;
|
||||
matchRate: number;
|
||||
rows: LiveConditionRowDto[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 백엔드 Ta4j 조건 평가 — 3초 주기 폴링용 (종목별, 캐시 없음) */
|
||||
export async function fetchLiveConditionStatus(
|
||||
market: string,
|
||||
strategyId: number,
|
||||
): Promise<LiveConditionStatusDto | null> {
|
||||
const params = new URLSearchParams({
|
||||
market,
|
||||
strategyId: String(strategyId),
|
||||
});
|
||||
return request<LiveConditionStatusDto>(`/strategy/live-conditions?${params}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
}
|
||||
|
||||
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */
|
||||
export async function pinCandleWatch(market: string, candleType: string): Promise<void> {
|
||||
try {
|
||||
await fetch(
|
||||
`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
} catch {
|
||||
/* pin 실패해도 평가 API는 시도 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'strategy-editor', 'backtest', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
dashboard: '대시보드',
|
||||
chart: '실시간차트',
|
||||
paper: '모의투자',
|
||||
virtual: '가상투자',
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
|
||||
@@ -7,6 +7,7 @@ import SockJS from 'sockjs-client';
|
||||
import { getStompSockJsUrl } from './backendApi';
|
||||
|
||||
type MessageHandler = (msg: IMessage) => void;
|
||||
type ConnectionListener = (connected: boolean) => void;
|
||||
|
||||
interface TopicEntry {
|
||||
handlers: Set<MessageHandler>;
|
||||
@@ -16,8 +17,14 @@ interface TopicEntry {
|
||||
let client: StompClient | null = null;
|
||||
let connected = false;
|
||||
const topics = new Map<string, TopicEntry>();
|
||||
const connectionListeners = new Set<ConnectionListener>();
|
||||
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function notifyConnection(next: boolean): void {
|
||||
connected = next;
|
||||
connectionListeners.forEach(l => l(next));
|
||||
}
|
||||
|
||||
function handlerCount(): number {
|
||||
let n = 0;
|
||||
for (const entry of topics.values()) n += entry.handlers.size;
|
||||
@@ -51,12 +58,12 @@ function getOrCreateClient(): StompClient {
|
||||
heartbeatIncoming: 10_000,
|
||||
heartbeatOutgoing: 10_000,
|
||||
onConnect: () => {
|
||||
connected = true;
|
||||
cancelPendingDisconnect();
|
||||
notifyConnection(true);
|
||||
resubscribeAll();
|
||||
},
|
||||
onDisconnect: () => {
|
||||
connected = false;
|
||||
notifyConnection(false);
|
||||
for (const entry of topics.values()) {
|
||||
entry.stompSub = undefined;
|
||||
}
|
||||
@@ -68,7 +75,7 @@ function getOrCreateClient(): StompClient {
|
||||
console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev);
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
connected = false;
|
||||
notifyConnection(false);
|
||||
for (const entry of topics.values()) {
|
||||
entry.stompSub = undefined;
|
||||
}
|
||||
@@ -95,10 +102,17 @@ function scheduleDisconnect(): void {
|
||||
return;
|
||||
}
|
||||
client.deactivate();
|
||||
connected = false;
|
||||
notifyConnection(false);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** STOMP 연결 상태 변경 구독 */
|
||||
export function subscribeStompConnection(listener: ConnectionListener): () => void {
|
||||
connectionListeners.add(listener);
|
||||
listener(connected && Boolean(client?.connected));
|
||||
return () => { connectionListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP 토픽 구독 (참조 카운트). 반환 함수로 해제.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 전략 DSL → 차트 IndicatorConfig[] 변환 (가상투자 차트 팝업)
|
||||
*/
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import type { IndicatorConfig, Timeframe } from '../types';
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import {
|
||||
enrichIndicatorConfig,
|
||||
getIndicatorDef,
|
||||
getHLineLabel,
|
||||
type HLineDef,
|
||||
} from './indicatorRegistry';
|
||||
import { createDefaultSmaPlotVisibility, normalizeSmaConfig, smaPeriodKey } from './smaConfig';
|
||||
import { normalizeIchimokuConfig } from './ichimokuConfig';
|
||||
import { extractVirtualConditions } from './virtualStrategyConditions';
|
||||
|
||||
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: 'Psychological',
|
||||
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
||||
BWI: 'BBBandWidth',
|
||||
DONCHIAN: 'DonchianChannels',
|
||||
ICHIMOKU: 'IchimokuCloud',
|
||||
ATR: 'ATR',
|
||||
MFI: 'MFI',
|
||||
};
|
||||
|
||||
interface IndicatorRef {
|
||||
dslType: string;
|
||||
registryType: string;
|
||||
period?: number;
|
||||
leftPeriod?: number;
|
||||
rightPeriod?: number;
|
||||
targetValue?: number | null;
|
||||
}
|
||||
|
||||
type ParamRecord = Record<string, number | string | boolean>;
|
||||
type GetParams = (type: string, defaults?: ParamRecord) => ParamRecord;
|
||||
type GetVisual = (
|
||||
type: string,
|
||||
plots: import('./indicatorRegistry').PlotDef[],
|
||||
hlines: HLineDef[],
|
||||
) => {
|
||||
plots: import('./indicatorRegistry').PlotDef[];
|
||||
hlines: HLineDef[];
|
||||
cloudColors?: import('./ichimokuConfig').IchimokuCloudColors;
|
||||
};
|
||||
|
||||
let _idSeq = 0;
|
||||
function newIndId(): string {
|
||||
_idSeq += 1;
|
||||
return `vstrat_${_idSeq}_${Date.now()}`;
|
||||
}
|
||||
|
||||
export function candleTypeToTimeframe(candleType: string): Timeframe {
|
||||
const c = (candleType ?? '1m').trim().toLowerCase();
|
||||
if (c === '1d') return '1D';
|
||||
if (c === '1h') return '1h';
|
||||
if (c === '4h') return '4h';
|
||||
if (['1m', '3m', '5m', '15m', '30m'].includes(c)) return c as Timeframe;
|
||||
return '1m';
|
||||
}
|
||||
|
||||
export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Timeframe[] {
|
||||
if (!strategy) return ['1m'];
|
||||
const conditions = extractVirtualConditions(strategy);
|
||||
const set = new Set<Timeframe>();
|
||||
for (const row of conditions) {
|
||||
set.add(candleTypeToTimeframe(row.timeframe));
|
||||
}
|
||||
if (set.size === 0) set.add('1m');
|
||||
return [...set].sort((a, b) => {
|
||||
const order: Timeframe[] = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveStrategyPrimaryTimeframe(strategy: StrategyDto | undefined): Timeframe {
|
||||
const tfs = resolveStrategyTimeframes(strategy);
|
||||
return tfs[0] ?? '1m';
|
||||
}
|
||||
|
||||
function walkIndicatorRefs(
|
||||
node: LogicNode | null | undefined,
|
||||
out: IndicatorRef[],
|
||||
seen: Set<string>,
|
||||
): void {
|
||||
if (!node) return;
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
node.children?.forEach(c => walkIndicatorRefs(c, out, seen));
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition;
|
||||
const dslType = c.indicatorType;
|
||||
const registryType = DSL_TO_REGISTRY[dslType] ?? dslType;
|
||||
const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${c.targetValue ?? ''}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
dslType,
|
||||
registryType,
|
||||
period: c.period,
|
||||
leftPeriod: c.leftPeriod,
|
||||
rightPeriod: c.rightPeriod,
|
||||
targetValue: c.targetValue ?? c.compareValue ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
|
||||
}
|
||||
|
||||
function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] {
|
||||
const out: IndicatorRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
||||
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyTargetHlines(
|
||||
hlines: HLineDef[],
|
||||
target: number | null | undefined,
|
||||
): HLineDef[] {
|
||||
if (target == null || Number.isNaN(target)) return hlines;
|
||||
const next = hlines.map(h => ({ ...h }));
|
||||
const prices = next.map(h => h.price);
|
||||
const idx = next.findIndex(h => Math.abs(h.price - target) < 1e-6);
|
||||
if (idx >= 0) {
|
||||
next[idx] = { ...next[idx], visible: true, price: target };
|
||||
return next;
|
||||
}
|
||||
next.push({
|
||||
price: target,
|
||||
color: '#ffb300',
|
||||
label: getHLineLabel(target, [...prices, target]),
|
||||
visible: true,
|
||||
lineStyle: 'dashed',
|
||||
lineWidth: 1,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildOneIndicator(
|
||||
ref: IndicatorRef,
|
||||
getParams: GetParams,
|
||||
getVisual: GetVisual,
|
||||
): IndicatorConfig | null {
|
||||
const def = getIndicatorDef(ref.registryType);
|
||||
if (!def) return null;
|
||||
|
||||
const params = getParams(ref.registryType, def.defaultParams as ParamRecord);
|
||||
const { plots, hlines, cloudColors } = getVisual(ref.registryType, def.plots, def.hlines ?? []);
|
||||
|
||||
let cfg: IndicatorConfig = {
|
||||
id: newIndId(),
|
||||
type: def.type,
|
||||
params: { ...params },
|
||||
plots,
|
||||
hlines,
|
||||
...(cloudColors ? { cloudColors } : {}),
|
||||
};
|
||||
|
||||
if (ref.registryType === 'SMA') {
|
||||
const periods = [ref.period, ref.leftPeriod, ref.rightPeriod]
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
if (periods.length > 0) {
|
||||
const p = { ...cfg.params };
|
||||
periods.slice(0, 11).forEach((val, i) => {
|
||||
p[smaPeriodKey(i + 1)] = val;
|
||||
});
|
||||
cfg = { ...cfg, params: p };
|
||||
}
|
||||
cfg = normalizeSmaConfig({
|
||||
...cfg,
|
||||
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
||||
});
|
||||
} else if (ref.period != null && ref.period > 0) {
|
||||
cfg = {
|
||||
...cfg,
|
||||
params: { ...cfg.params, length: ref.period },
|
||||
};
|
||||
}
|
||||
|
||||
if (ref.registryType === 'IchimokuCloud') {
|
||||
cfg = normalizeIchimokuConfig(cfg);
|
||||
}
|
||||
|
||||
if (ref.targetValue != null) {
|
||||
cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) };
|
||||
}
|
||||
|
||||
return enrichIndicatorConfig(cfg);
|
||||
}
|
||||
|
||||
/** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */
|
||||
export function buildStrategyChartIndicators(
|
||||
strategy: StrategyDto | undefined,
|
||||
getParams: GetParams,
|
||||
getVisual: GetVisual,
|
||||
): IndicatorConfig[] {
|
||||
if (!strategy) return [];
|
||||
|
||||
const refs = collectIndicatorRefs(strategy);
|
||||
const byType = new Map<string, IndicatorRef>();
|
||||
|
||||
for (const ref of refs) {
|
||||
const existing = byType.get(ref.registryType);
|
||||
if (!existing) {
|
||||
byType.set(ref.registryType, ref);
|
||||
continue;
|
||||
}
|
||||
byType.set(ref.registryType, {
|
||||
...existing,
|
||||
period: existing.period ?? ref.period,
|
||||
leftPeriod: existing.leftPeriod ?? ref.leftPeriod,
|
||||
rightPeriod: existing.rightPeriod ?? ref.rightPeriod,
|
||||
targetValue: existing.targetValue ?? ref.targetValue,
|
||||
});
|
||||
}
|
||||
|
||||
const result: IndicatorConfig[] = [];
|
||||
for (const ref of byType.values()) {
|
||||
const cfg = buildOneIndicator(ref, getParams, getVisual);
|
||||
if (cfg) result.push(cfg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
formatIndicatorValue,
|
||||
isConditionMet,
|
||||
type VirtualConditionRow,
|
||||
} from './virtualStrategyConditions';
|
||||
|
||||
export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
|
||||
|
||||
export interface ConditionMetric {
|
||||
row: VirtualConditionRow & { currentValue: number | null };
|
||||
status: ConditionStatus;
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
export type TrafficLightState = 'red' | 'yellow' | 'blue';
|
||||
|
||||
const PENDING_PROGRESS = 72;
|
||||
|
||||
/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */
|
||||
export function computeConditionProgress(
|
||||
conditionType: string,
|
||||
current: number | null,
|
||||
target: number | null,
|
||||
): number | null {
|
||||
if (current == null || target == null) return null;
|
||||
const met = isConditionMet(conditionType, current, target);
|
||||
if (met === true) return 100;
|
||||
|
||||
const absTarget = Math.abs(target);
|
||||
const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(current), 1);
|
||||
|
||||
switch (conditionType) {
|
||||
case 'LT':
|
||||
case 'CROSS_DOWN':
|
||||
case 'LTE': {
|
||||
if (current <= target) return 100;
|
||||
const gap = current - target;
|
||||
return Math.min(99, Math.max(0, 100 - (gap / scale) * 100));
|
||||
}
|
||||
case 'GT':
|
||||
case 'CROSS_UP':
|
||||
case 'GTE': {
|
||||
if (current >= target) return 100;
|
||||
if (target <= 0 && current <= 0) return 0;
|
||||
return Math.min(99, Math.max(0, (current / scale) * 100));
|
||||
}
|
||||
case 'EQ': {
|
||||
const diff = Math.abs(current - target);
|
||||
return Math.min(99, Math.max(0, 100 - (diff / scale) * 100));
|
||||
}
|
||||
default:
|
||||
return Math.min(99, Math.max(0, 100 - (Math.abs(current - target) / scale) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
export function getConditionStatus(
|
||||
conditionType: string,
|
||||
current: number | null,
|
||||
target: number | null,
|
||||
satisfied?: boolean | null,
|
||||
): ConditionStatus {
|
||||
if (satisfied === true) return 'match';
|
||||
if (satisfied === false) {
|
||||
const progress = computeConditionProgress(conditionType, current, target);
|
||||
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
|
||||
return 'nomatch';
|
||||
}
|
||||
const met = isConditionMet(conditionType, current, target);
|
||||
if (met === true) return 'match';
|
||||
if (met === false) {
|
||||
const progress = computeConditionProgress(conditionType, current, target);
|
||||
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
|
||||
return 'nomatch';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function formatStrategyThreshold(
|
||||
conditionType: string,
|
||||
target: number | null,
|
||||
conditionLabel: string,
|
||||
): string {
|
||||
if (target == null) return '—';
|
||||
const v = formatIndicatorValue(target);
|
||||
switch (conditionType) {
|
||||
case 'LT':
|
||||
case 'CROSS_DOWN':
|
||||
return `< ${v}`;
|
||||
case 'GT':
|
||||
case 'CROSS_UP':
|
||||
return `> ${v}`;
|
||||
case 'GTE':
|
||||
return `≥ ${v}`;
|
||||
case 'LTE':
|
||||
return `≤ ${v}`;
|
||||
case 'EQ':
|
||||
return `= ${v}`;
|
||||
case 'NEQ':
|
||||
return `≠ ${v}`;
|
||||
default:
|
||||
return `${conditionLabel} ${v}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConditionMetrics(
|
||||
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
|
||||
): ConditionMetric[] {
|
||||
return rows.map(row => ({
|
||||
row,
|
||||
status: getConditionStatus(
|
||||
row.conditionType,
|
||||
row.currentValue,
|
||||
row.targetValue,
|
||||
row.satisfied,
|
||||
),
|
||||
progress: row.satisfied === true
|
||||
? 100
|
||||
: row.satisfied === false
|
||||
? (computeConditionProgress(row.conditionType, row.currentValue, row.targetValue) ?? 0)
|
||||
: computeConditionProgress(row.conditionType, row.currentValue, row.targetValue),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */
|
||||
export function computeMatchRate(
|
||||
metrics: ConditionMetric[],
|
||||
backendMatchRate?: number,
|
||||
): number {
|
||||
if (backendMatchRate != null && Number.isFinite(backendMatchRate)) {
|
||||
return Math.round(Math.min(100, Math.max(0, backendMatchRate)));
|
||||
}
|
||||
const evaluable = metrics.filter(m => m.status !== 'unknown');
|
||||
if (evaluable.length === 0) return 0;
|
||||
const met = evaluable.filter(m => m.status === 'match').length;
|
||||
return Math.round((met / evaluable.length) * 100);
|
||||
}
|
||||
|
||||
export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState {
|
||||
if (matchRate >= 100) return 'blue';
|
||||
const hasPending = metrics.some(m => m.status === 'pending');
|
||||
const hasPartial = metrics.some(m => m.status === 'match');
|
||||
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export function formatUpdatedTime(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 전략 DSL → 가상투자 이퀄라이저 표시용 조건 추출
|
||||
*/
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { CONDITION_LABEL } from './strategyTypes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import { getIndicatorDef } from './indicatorRegistry';
|
||||
|
||||
export interface VirtualConditionRow {
|
||||
id: string;
|
||||
indicatorType: string;
|
||||
displayName: string;
|
||||
conditionType: string;
|
||||
conditionLabel: string;
|
||||
targetValue: number | null;
|
||||
timeframe: string;
|
||||
side: 'buy' | 'sell';
|
||||
/** 지표 계산 plot id (RSI, MACD 등) */
|
||||
plotKey: string;
|
||||
/** 백엔드 Ta4j Rule.isSatisfied 결과 (있으면 우선) */
|
||||
satisfied?: boolean | null;
|
||||
/** 백엔드 임계값 라벨 (MA cross 등) */
|
||||
thresholdLabel?: string;
|
||||
}
|
||||
|
||||
function walk(
|
||||
node: LogicNode | null | undefined,
|
||||
timeframe: string,
|
||||
side: 'buy' | 'sell',
|
||||
out: VirtualConditionRow[],
|
||||
seen: Set<string>,
|
||||
): void {
|
||||
if (!node) return;
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const tf = node.candleType ?? timeframe;
|
||||
node.children?.forEach(c => walk(c, tf, side, out, seen));
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition;
|
||||
const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const def = getIndicatorDef(c.indicatorType);
|
||||
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
|
||||
const target = c.targetValue ?? c.compareValue ?? null;
|
||||
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
|
||||
|
||||
out.push({
|
||||
id: `${node.id}-${side}`,
|
||||
indicatorType: c.indicatorType,
|
||||
displayName: def?.koreanName ?? def?.shortName ?? c.indicatorType,
|
||||
conditionType: c.conditionType,
|
||||
conditionLabel: condLabel,
|
||||
targetValue: target,
|
||||
timeframe,
|
||||
side,
|
||||
plotKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
|
||||
}
|
||||
|
||||
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
|
||||
if (!strategy) return [];
|
||||
const out: VirtualConditionRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen);
|
||||
walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 조건 충족 여부 (단순 임계값 비교) */
|
||||
export function isConditionMet(
|
||||
conditionType: string,
|
||||
current: number | null,
|
||||
target: number | null,
|
||||
): boolean | null {
|
||||
if (current == null || target == null) return null;
|
||||
switch (conditionType) {
|
||||
case 'GT': case 'CROSS_UP': return current > target;
|
||||
case 'LT': case 'CROSS_DOWN': return current < target;
|
||||
case 'GTE': return current >= target;
|
||||
case 'LTE': return current <= target;
|
||||
case 'EQ': return Math.abs(current - target) < 1e-6;
|
||||
case 'NEQ': return Math.abs(current - target) >= 1e-6;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatIndicatorValue(v: number | null): string {
|
||||
if (v == null || Number.isNaN(v)) return '—';
|
||||
if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return v.toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
strategyId: number | null;
|
||||
koreanName?: string;
|
||||
englishName?: string;
|
||||
}
|
||||
|
||||
export interface VirtualSessionConfig {
|
||||
globalStrategyId: number | null;
|
||||
executionType: 'CANDLE_CLOSE' | 'REALTIME_TICK';
|
||||
/** LONG_ONLY = 보유 자산 기준, SIGNAL_ONLY = 순수 지표 기준 */
|
||||
positionMode: 'LONG_ONLY' | 'SIGNAL_ONLY';
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const TARGETS_KEY = 'gc_virtual_targets_v1';
|
||||
const SESSION_KEY = 'gc_virtual_session_v1';
|
||||
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
|
||||
|
||||
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
|
||||
export type VirtualCardViewMode = 'summary' | 'detail';
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(TARGETS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return defaultSession();
|
||||
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
} catch {
|
||||
return defaultSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function defaultSession(): VirtualSessionConfig {
|
||||
return {
|
||||
globalStrategyId: null,
|
||||
executionType: 'CANDLE_CLOSE',
|
||||
positionMode: 'LONG_ONLY',
|
||||
running: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARD_VIEW_KEY);
|
||||
return raw === 'detail' ? 'detail' : 'summary';
|
||||
} catch {
|
||||
return 'summary';
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
try {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
Reference in New Issue
Block a user