추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
+6
View File
@@ -418,6 +418,8 @@ export interface AppSettingsDto {
paperMinOrderKrw?: number;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
/** 가상매매 투자대상 목록 최대 종목 수 */
virtualTargetMaxCount?: number;
/** PAPER | LIVE | BOTH */
tradingMode?: string;
liveAutoTradeEnabled?: boolean;
@@ -431,6 +433,8 @@ export interface AppSettingsDto {
fcmPushEnabled?: boolean;
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
displayTimezone?: string;
/** 추세검색 기본 설정 */
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
}
export interface LiveSummaryDto {
@@ -1025,6 +1029,8 @@ export interface LiveStrategySettingsDto {
skipWatchlistSync?: boolean;
/** true면 gc_app_settings 전역 템플릿 갱신 생략 */
skipGlobalTemplate?: boolean;
/** 가상투자 투자대상 고정 — ON 이면 목록에서 삭제 불가 */
isPinned?: boolean;
}
/** 실시간 전략 체크 설정 로드 */
+4 -3
View File
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId =
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
| 'paper' | 'alert' | 'network' | 'admin';
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'alert', 'network', 'admin',
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'trend-search', 'alert', 'network', 'admin',
];
export const ALL_MENU_IDS = [
@@ -42,7 +42,8 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략',
settings_paper: '설정 · 가상투자',
settings_paper: '설정 · 가상매매',
'settings_trend-search': '설정 · 추세검색',
settings_alert: '설정 · 알림',
settings_network: '설정 · 네트워크',
settings_admin: '설정 · 관리자',
@@ -0,0 +1,128 @@
/**
* 추세검색 앱 설정 (gc_app_settings.trend_search_settings_json)
*/
import type { TrendSearchRequest } from './backendApi';
import { DEFAULT_TREND_SEARCH_REQUEST } from './backendApi';
import {
BULLISH_WEIGHT_ITEMS,
DEFAULT_BULLISH_WEIGHTS,
type BullishWeightKey,
} from './trendSearchBullishWeights';
export interface TrendSearchAppSettings {
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
weightMacdMomentum: number;
weightPricePosition: number;
minTrendScore: number;
/** 결과 목록 최대 개수 */
limit: number;
/** 스캔 대상 종목 수 (거래대금 상위) */
scanLimit: number;
/** 자동 갱신 간격(초) */
autoRefreshSeconds: number;
/** 검색 후 상위 N위까지 투자대상 자동추가 */
autoAddTargetsEnabled: boolean;
autoAddTopRank: number;
/** 자동추가 시 추세점수(matchRate) 하한 */
autoAddMinScore: number;
}
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
weightMacdMomentum: DEFAULT_BULLISH_WEIGHTS.weightMacdMomentum,
weightPricePosition: DEFAULT_BULLISH_WEIGHTS.weightPricePosition,
minTrendScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
limit: DEFAULT_TREND_SEARCH_REQUEST.limit,
scanLimit: DEFAULT_TREND_SEARCH_REQUEST.scanLimit,
autoRefreshSeconds: 3,
autoAddTargetsEnabled: false,
autoAddTopRank: 5,
autoAddMinScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
};
/** 자동 갱신 간격 선택지 (초) */
export const TREND_SEARCH_REFRESH_OPTIONS = [3, 5, 10, 30, 60] as const;
export function resolveTrendSearchAppSettings(
raw?: Partial<TrendSearchAppSettings> | null,
): TrendSearchAppSettings {
const d = DEFAULT_TREND_SEARCH_APP_SETTINGS;
if (!raw || typeof raw !== 'object') return { ...d };
const sec = Number(raw.autoRefreshSeconds);
const rank = Number(raw.autoAddTopRank);
const limit = Number(raw.limit);
const scanLimit = Number(raw.scanLimit);
return {
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
weightMacdMomentum: clampWeight(raw.weightMacdMomentum, d.weightMacdMomentum),
weightPricePosition: clampWeight(raw.weightPricePosition, d.weightPricePosition),
minTrendScore: clampInt(raw.minTrendScore, 0, 100, d.minTrendScore),
limit: clampInt(limit, 5, 50, d.limit),
scanLimit: clampInt(scanLimit, 20, 120, d.scanLimit),
autoRefreshSeconds: TREND_SEARCH_REFRESH_OPTIONS.includes(sec as typeof TREND_SEARCH_REFRESH_OPTIONS[number])
? sec
: d.autoRefreshSeconds,
autoAddTargetsEnabled: raw.autoAddTargetsEnabled === true,
autoAddTopRank: clampInt(rank, 1, 50, d.autoAddTopRank),
autoAddMinScore: clampInt(raw.autoAddMinScore, 0, 100, d.autoAddMinScore),
};
}
function clampWeight(v: unknown, fallback: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.max(0, Math.min(100, Math.round(n / 5) * 5));
}
function clampInt(v: unknown, min: number, max: number, fallback: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}
export function mergeTrendSearchRequest(
base: TrendSearchRequest,
settings: TrendSearchAppSettings,
): TrendSearchRequest {
return {
...base,
weightMaAlignment: settings.weightMaAlignment,
weightMaSlope: settings.weightMaSlope,
weightAdxTrend: settings.weightAdxTrend,
weightMacdMomentum: settings.weightMacdMomentum,
weightPricePosition: settings.weightPricePosition,
minTrendScore: settings.minTrendScore,
limit: settings.limit,
scanLimit: settings.scanLimit,
};
}
/** 검색 요청 → 앱 설정에 반영할 필드 */
export function extractTrendSearchAppSettings(
req: Pick<TrendSearchRequest, BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'>,
): Pick<
TrendSearchAppSettings,
BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'
> {
return {
weightMaAlignment: req.weightMaAlignment,
weightMaSlope: req.weightMaSlope,
weightAdxTrend: req.weightAdxTrend,
weightMacdMomentum: req.weightMacdMomentum,
weightPricePosition: req.weightPricePosition,
minTrendScore: req.minTrendScore,
limit: req.limit,
scanLimit: req.scanLimit,
};
}
export function formatTrendRefreshLabel(seconds: number): string {
if (seconds < 60) return `${seconds}`;
return `${seconds / 60}`;
}
@@ -0,0 +1,39 @@
import { sortTrendSearchByStrength } from './trendSearchMetrics';
import { resolveVirtualTargetNames } from './virtualTargetNames';
import type { TrendSearchResultDto } from './backendApi';
import type { TrendSearchAppSettings } from './trendSearchAppSettings';
import { addVirtualTarget } from './virtualTargetMutations';
import { loadVirtualTargets } from './virtualTradingStorage';
import { isVirtualTargetAddAllowed } from './virtualTargetLimits';
import { getVirtualTargetMaxCount } from '../hooks/useAppSettings';
/** 추세검색 결과 상위 순위 → 가상매매 투자대상 자동추가 */
export async function autoAddTrendSearchTargets(
results: TrendSearchResultDto[],
settings: Pick<TrendSearchAppSettings, 'autoAddTargetsEnabled' | 'autoAddTopRank' | 'autoAddMinScore'>,
timeframe: string,
): Promise<void> {
if (!settings.autoAddTargetsEnabled || results.length === 0) return;
const sorted = sortTrendSearchByStrength(results);
const topN = Math.max(1, Math.min(50, settings.autoAddTopRank));
const minScore = Math.max(0, Math.min(100, settings.autoAddMinScore));
const candidates = sorted
.slice(0, topN)
.filter(r => r.matchRate >= minScore);
let targets = loadVirtualTargets();
const maxCount = getVirtualTargetMaxCount();
for (const row of candidates) {
if (!isVirtualTargetAddAllowed(targets.length, maxCount)) break;
if (targets.some(t => t.market === row.market)) continue;
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
targets = await addVirtualTarget(
row.market,
{ koreanName, englishName, candleType: timeframe },
{ showLimitAlert: false },
);
}
}
+41 -24
View File
@@ -55,6 +55,18 @@ export function bullishWeightsFromRequest(
return BULLISH_WEIGHT_ITEMS.map(item => req[item.key] ?? DEFAULT_BULLISH_WEIGHTS[item.key]);
}
export const WEIGHT_STEP = 5;
export function snapToWeightStep(value: number): number {
return Math.max(0, Math.min(100, Math.round(value / WEIGHT_STEP) * WEIGHT_STEP));
}
/** range 슬라이더 트랙 클릭 위치 → 5점 단위 배점 */
export function weightFromSliderPointer(clientX: number, rect: DOMRect): number {
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return snapToWeightStep(ratio * 100);
}
export function sumWeights(weights: number[]): number {
return weights.reduce((a, b) => a + b, 0);
}
@@ -71,43 +83,48 @@ export function adjustBullishWeight(
const idx = keys.indexOf(changedKey);
if (idx < 0) return current;
const clamped = Math.max(0, Math.min(100, Math.round(newValue)));
const oldValue = current[changedKey];
const clamped = snapToWeightStep(newValue);
const oldValue = snapToWeightStep(current[changedKey]);
if (clamped === oldValue) return current;
const result: Record<BullishWeightKey, number> = { ...current };
for (const k of keys) result[k] = snapToWeightStep(result[k]);
result[changedKey] = clamped;
const delta = clamped - oldValue;
const otherKeys = keys.filter(k => k !== changedKey);
if (delta > 0) {
let remaining = delta;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const take = i === otherKeys.length - 1
? remaining
: Math.floor(delta / otherKeys.length);
result[k] = Math.max(0, result[k] - take);
remaining -= take;
const distribute = (amount: number, mode: 'take' | 'give') => {
let remaining = amount;
let idx = 0;
let guard = 0;
while (remaining > 0 && guard < 200) {
const k = otherKeys[idx % otherKeys.length];
if (mode === 'take') {
const take = Math.min(WEIGHT_STEP, remaining, result[k]);
if (take > 0) {
result[k] -= take;
remaining -= take;
}
} else {
const give = Math.min(WEIGHT_STEP, remaining, 100 - result[k]);
if (give > 0) {
result[k] += give;
remaining -= give;
}
}
idx += 1;
guard += 1;
}
} else {
const add = Math.abs(delta);
let remaining = add;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const give = i === otherKeys.length - 1
? remaining
: Math.floor(add / otherKeys.length);
result[k] += give;
remaining -= give;
}
}
};
if (delta > 0) distribute(delta, 'take');
else if (delta < 0) distribute(-delta, 'give');
const total = keys.reduce((s, k) => s + result[k], 0);
if (total !== 100) {
const fixKey = otherKeys[0] ?? changedKey;
result[fixKey] = Math.max(0, result[fixKey] + (100 - total));
result[fixKey] = snapToWeightStep(Math.max(0, Math.min(100, result[fixKey] + (100 - total))));
}
return result;
@@ -24,6 +24,7 @@ export async function syncVirtualTargetsToBackend(
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
candleType: normalizeStartCandleType(resolveTargetCandleType(t)),
isPinned: !!t.pinned,
...shared,
}),
),
+21
View File
@@ -0,0 +1,21 @@
/** 가상매매 투자대상 목록 최대 개수 (설정 · gc_app_settings.virtual_target_max_count) */
export const DEFAULT_VIRTUAL_TARGET_MAX = 20;
export const MIN_VIRTUAL_TARGET_MAX = 1;
export const MAX_VIRTUAL_TARGET_MAX = 100;
export function clampVirtualTargetMax(n: number): number {
if (!Number.isFinite(n)) return DEFAULT_VIRTUAL_TARGET_MAX;
return Math.min(MAX_VIRTUAL_TARGET_MAX, Math.max(MIN_VIRTUAL_TARGET_MAX, Math.floor(n)));
}
export function virtualTargetLimitMessage(max: number): string {
return `투자대상은 최대 ${max}종목까지 등록할 수 있습니다. 설정 › 가상매매에서 한도를 확인하세요.`;
}
export function isVirtualTargetAddAllowed(
currentCount: number,
maxCount: number = DEFAULT_VIRTUAL_TARGET_MAX,
): boolean {
return currentCount < clampVirtualTargetMax(maxCount);
}
@@ -0,0 +1,122 @@
import { saveLiveStrategySettings } from './backendApi';
import { getVirtualTargetMaxCount } from '../hooks/useAppSettings';
import {
isVirtualTargetAddAllowed,
virtualTargetLimitMessage,
} from '../utils/virtualTargetLimits';
import { normalizeStartCandleType } from './strategyStartNodes';
import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync';
import {
loadVirtualSession,
loadVirtualTargets,
notifyVirtualSessionChanged,
saveVirtualTargets,
type VirtualTargetItem,
} from './virtualTradingStorage';
export interface VirtualTargetMeta {
koreanName?: string;
englishName?: string;
/** 추세검색 캔들 주기 → 투자대상 평가 분봉 기본값 */
candleType?: string;
}
export interface AddVirtualTargetOptions {
/** false면 한도 초과 시 알림 없이 스킵 (자동추가용) */
showLimitAlert?: boolean;
}
/** 가상투자 투자대상 목록에 종목 추가 (localStorage + DB) */
export async function addVirtualTarget(
market: string,
meta: VirtualTargetMeta = {},
opts: AddVirtualTargetOptions = {},
): Promise<VirtualTargetItem[]> {
const showLimitAlert = opts.showLimitAlert !== false;
const targets = loadVirtualTargets();
if (targets.some(t => t.market === market)) return targets;
const maxCount = getVirtualTargetMaxCount();
if (!isVirtualTargetAddAllowed(targets.length, maxCount)) {
if (showLimitAlert) window.alert(virtualTargetLimitMessage(maxCount));
return targets;
}
const session = loadVirtualSession();
const en = meta.englishName ?? market.replace(/^KRW-/, '');
const item: VirtualTargetItem = {
market,
strategyId: session.globalStrategyId,
koreanName: meta.koreanName,
englishName: en,
candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined,
};
const next = [...targets, item];
saveVirtualTargets(next);
await saveLiveStrategySettings({
market,
strategyId: session.globalStrategyId,
isLiveCheck: session.running,
isPinned: false,
executionType: session.executionType,
positionMode: session.positionMode,
candleType: item.candleType ?? '1m',
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
if (session.running && session.globalStrategyId) {
await syncVirtualTargetsToBackend(next, session, true);
}
notifyVirtualSessionChanged();
return next;
}
/** 가상투자 투자대상 목록에서 종목 제거 (localStorage + DB 비활성화) */
/** 투자대상 고정 상태 DB 저장 */
export async function persistVirtualTargetPinned(market: string, pinned: boolean): Promise<void> {
const session = loadVirtualSession();
const item = loadVirtualTargets().find(t => t.market === market);
await saveLiveStrategySettings({
market,
strategyId: item?.strategyId ?? session.globalStrategyId,
isLiveCheck: session.running,
isPinned: pinned,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
}
export async function removeVirtualTarget(market: string): Promise<VirtualTargetItem[]> {
const targets = loadVirtualTargets();
const item = targets.find(t => t.market === market);
if (!item) return targets;
if (item.pinned) return targets;
const session = loadVirtualSession();
const next = targets.filter(t => t.market !== market);
saveVirtualTargets(next);
await saveLiveStrategySettings({
market,
strategyId: session.globalStrategyId,
isLiveCheck: false,
isPinned: false,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
if (session.running) {
await syncVirtualTargetsToBackend(next, session, true);
}
notifyVirtualSessionChanged();
return next;
}
+25
View File
@@ -0,0 +1,25 @@
import { getKoreanName } from './marketNameCache';
/** 백엔드/캐시에 마켓 코드가 한글명으로 들어온 경우 보정 */
export function isInvalidStoredKoreanName(market: string, name?: string | null): boolean {
if (!name || !name.trim()) return true;
const trimmed = name.trim();
if (trimmed === market) return true;
if (trimmed.startsWith('KRW-')) return true;
const sym = market.replace(/^KRW-/, '');
if (trimmed === sym) return true;
return false;
}
/** 투자대상 표시·저장용 한글명·영문 심볼 */
export function resolveVirtualTargetNames(
market: string,
koreanName?: string | null,
englishName?: string | null,
): { koreanName: string; englishName: string } {
const en = (englishName?.trim() || market.replace(/^KRW-/, '')).replace(/^KRW-/, '');
const ko = !isInvalidStoredKoreanName(market, koreanName)
? koreanName!.trim()
: getKoreanName(market);
return { koreanName: ko, englishName: en };
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { normalizeStartCandleType } from './strategyStartNodes';
import { resolveVirtualTargetNames } from './virtualTargetNames';
/** 가상투자 대상 종목·세션 설정 localStorage */
@@ -9,6 +10,8 @@ export interface VirtualTargetItem {
candleType?: string;
koreanName?: string;
englishName?: string;
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
pinned?: boolean;
}
export interface VirtualSessionConfig {
@@ -31,7 +34,15 @@ export function loadVirtualTargets(): VirtualTargetItem[] {
const raw = localStorage.getItem(TARGETS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as VirtualTargetItem[];
return Array.isArray(parsed) ? parsed : [];
if (!Array.isArray(parsed)) return [];
return parsed.map(t => {
const { koreanName, englishName } = resolveVirtualTargetNames(
t.market,
t.koreanName,
t.englishName,
);
return { ...t, koreanName, englishName };
});
} catch {
return [];
}