Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/BasicEditorInteraction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,15 @@ describe('Basic editor interactions', () => {
expect(budgetNarrative).toHaveClass('tone-buy');
expect(budgetNarrative.querySelectorAll('b')).toHaveLength(2);

const buyRsi = screen.getByTestId('buy-rsi-block');
await user.click(within(buyRsi).getByRole('combobox', { name: 'RSI 반등 방향' }));
await user.click(screen.getByRole('option', { name: '상승' }));
expect(blockNarrative).toHaveTextContent('RSI가 기준선에서 위로 반등할 때');

const blocks = await openBlocks(user);
await user.click(within(blocks).getByRole('button', { name: 'MACD 전환 블록 추가' }));
const narratives = within(buyCard).getAllByTestId('basic-narrative-block');
expect(narratives[0]).toHaveTextContent('방향을 바꾸고');
expect(narratives[0]).toHaveTextContent('위로 반등하고');
expect(narratives[1]).toHaveTextContent('교차할 때');

fireEvent.keyDown(screen.getByRole('group', { name: '매도 전략 카드 이동 영역' }), { key: 'Enter' });
Expand Down
29 changes: 25 additions & 4 deletions src/StrategyApiView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ describe('Strategy API view', () => {
expect(screen.getByRole('dialog', { name: 'PARTITION 1 종목 관리' })).toHaveTextContent('SPY');
expect(loadOrder).toEqual(['document', 'lease']);
await user.click(screen.getByRole('button', { name: '완료' }));
await user.selectOptions(screen.getByRole('combobox', { name: 'PARTITION 01 기본 봉 주기' }), '1시간봉');
await user.click(screen.getByRole('button', { name: 'PARTITION 01 전략 미리보기' }));
expect(await screen.findByTestId('strategy-preview-canvas')).toBeInTheDocument();
expect(marketDataClient.getRecentBars).toHaveBeenCalledWith(
'spy-id', '30m', 300, expect.any(AbortSignal),
'spy-id', '1h', 300, expect.any(AbortSignal),
);
const save = screen.getByRole('button', { name: '저장' });
await waitFor(() => expect(save).toBeEnabled());
Expand Down Expand Up @@ -423,15 +424,28 @@ describe('Strategy API view', () => {
requestedEditSequence: 1, semanticHash: 'new-hash', elementCatalogVersionId: catalogId,
findings: [], completedAt: '2026-08-07T12:01:00Z',
};
const loadedValidation = {
...validation,
validationRunId: '21000000-0000-4000-8000-000000000000',
requestedEditSequence: 0,
semanticHash: 'old-hash',
};
const releaseInputs = {
executionPolicies: [{
version: 'policy-v1', brokerRulesVersion: 'market-v1', accountingRulesVersion: 'accounting-v1',
precisionRulesVersion: 'precision-v1', feePolicyId: 'fee-id', feeRateBps: 20,
buyingPowerBufferPolicyId: 'buffer-id', buyingPowerBufferBps: 1,
}, {
version: 'older-policy', brokerRulesVersion: 'older-market', accountingRulesVersion: 'older-accounting',
precisionRulesVersion: 'older-precision', feePolicyId: 'older-fee', feeRateBps: 30,
buyingPowerBufferPolicyId: 'older-buffer', buyingPowerBufferBps: 2,
}],
datasets: [{
id: 'dataset-id', feedCode: 'alpaca-sip', dataLayer: 'ADJUSTED', resolution: '1m',
periodStart: '2025-01-01', periodEnd: '2026-01-01', schemaVersion: 'market-bars-v2',
}, {
id: 'older-dataset', feedCode: 'alpaca-sip', dataLayer: 'ADJUSTED', resolution: '1m',
periodStart: '2024-01-01', periodEnd: '2025-01-01', schemaVersion: 'market-bars-v2',
}],
observedAt: '2026-08-07T12:01:00Z',
};
Expand All @@ -449,6 +463,7 @@ describe('Strategy API view', () => {
requestedEditSequence: input.clientRevision,
semanticHash: 'preview-hash',
})),
getCurrentValidations: vi.fn().mockResolvedValue([loadedValidation]),
validateStrategy: vi.fn().mockResolvedValue(validation),
getReleaseInputs: vi.fn().mockResolvedValue(releaseInputs),
releaseStrategy: vi.fn().mockResolvedValue({ botId: 'bot-id', backtestLane: 'BASIC' }),
Expand All @@ -466,10 +481,12 @@ describe('Strategy API view', () => {
render(<BasicEditor goBack={() => {}} strategyId={strategyId} authoringClient={authoringClient} catalogClient={{ getBasic: vi.fn().mockResolvedValue(catalog) }} onLaunchBot={onLaunchBot} />);
const save = await screen.findByRole('button', { name: '저장' });
await waitFor(() => expect(save).toBeEnabled());
expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeDisabled();
await waitFor(() => expect(authoringClient.getCurrentValidations).toHaveBeenCalledWith(expect.any(AbortSignal)));
await waitFor(() => expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeEnabled());
const rsiValue = screen.getByRole('spinbutton', { name: 'RSI 반등 값' });
await user.clear(rsiValue);
await user.type(rsiValue, '31');
expect(screen.getByRole('button', { name: '개인 봇 출시' })).toBeDisabled();
await waitFor(() => expect(authoringClient.previewValidation).toHaveBeenCalled());
await waitFor(() => expect(vi.mocked(authoringClient.previewValidation!).mock.calls.at(-1)?.[1].semanticDocument).toEqual(expect.objectContaining({
groups: expect.arrayContaining([expect.objectContaining({
Expand All @@ -492,8 +509,12 @@ describe('Strategy API view', () => {
})],
});
await user.click(screen.getByRole('button', { name: '개인 봇 출시' }));
expect(await screen.findByRole('combobox', { name: '실행 정책' })).toHaveValue('policy-v1');
await user.click(screen.getByRole('button', { name: '봇 출시하기' }));
const launchDialog = await screen.findByRole('dialog', { name: '개인 운용 봇 출시' });
await waitFor(() => expect(authoringClient.getReleaseInputs).toHaveBeenCalledTimes(1));
expect(within(launchDialog).queryByText('실행 정책')).not.toBeInTheDocument();
expect(within(launchDialog).queryByText('공식 백테스트 데이터')).not.toBeInTheDocument();
expect(within(launchDialog).queryByRole('combobox')).not.toBeInTheDocument();
await user.click(within(launchDialog).getByRole('button', { name: '봇 출시하기' }));

await waitFor(() => expect(authoringClient.releaseStrategy).toHaveBeenCalledWith(strategyId, expect.objectContaining({
validationRunId: validation.validationRunId,
Expand Down
149 changes: 147 additions & 2 deletions src/StrategyPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test } from 'vitest';
import { BasicEditor } from './views/StrategyViews';
import { StrategyPreviewChart } from './components/StrategyPreviewChart';
import { LanguageProvider } from './lib/i18n';
import {
PREVIEW_WINDOW,
bollinger,
evaluateStrategyPreview,
generatePreviewCandles,
identifyIndicator,
parseSignalRule,
parseSignalRules,
rsi,
splitPartitionSymbols,
} from './lib/strategyPreview';
Expand All @@ -30,20 +33,33 @@ const flowsOf = (buy: PreviewBlock[], sell: PreviewBlock[]): PreviewFlow[] => [
{ id: 'sell-1', label: '매도', side: 'sell', blocks: sell },
];

const candlesFrom = (closes: number[], volumes?: number[]) => closes.map((close, index) => ({
time: Date.UTC(2026, 6, 1 + index, 20, 0, 0) / 1000,
open: close,
high: close + 1,
low: close - 1,
close,
volume: volumes?.[index] ?? 10_000,
}));

describe('strategy preview engine', () => {
test('splits a partition symbol list into chart-selectable symbols', () => {
expect(splitPartitionSymbols('AAPL · MSFT · SPY')).toEqual(['AAPL', 'MSFT', 'SPY']);
// The placeholder option is not a tradable symbol.
expect(splitPartitionSymbols('종목 선택')).toEqual([]);
});

test('computes RSI on the standard Wilder scale', () => {
test('computes the official bounded-window RSI used by backtests', () => {
const rising = Array.from({ length: 40 }, (_, index) => 100 + index);
const values = rsi(rising, 14);
// The first 14 bars cannot have a value, and a pure uptrend pins RSI at 100.
expect(values.slice(0, 14).every((value) => value === null)).toBe(true);
expect(values[39]).toBeCloseTo(100, 5);
expect(values.every((value) => value === null || (value >= 0 && value <= 100))).toBe(true);

const officialFixture = [100, 101, 100, 99, 98, 97, 96, 95, 94, 94, 94, 94, 94, 94, 94];
expect(rsi(officialFixture, 14).at(-1)).toBeCloseTo(12.5, 8);
expect(rsi(Array.from({ length: 15 }, () => 100), 14).at(-1)).toBe(50);
});

test('keeps Bollinger bands ordered around the moving average', () => {
Expand All @@ -66,6 +82,18 @@ describe('strategy preview engine', () => {
expect(crossing.rule).toMatchObject({ kind: 'SMA', fastPeriod: 20, slowPeriod: 60 });
});

test('uses the editor 상승 direction as an upward crossing', () => {
const preview = evaluateStrategyPreview({
symbol: 'AAPL',
flows: [{
id: 'buy-up', label: '상승 매수', side: 'buy',
blocks: [{ label: 'RSI 반등', op: '상승', value: '30', tone: 'condition' }],
}],
});

expect(preview.flows[0].description).toBe('RSI(14) 30 상향 돌파');
});

test('reports indicators it cannot evaluate instead of inventing signals', () => {
const { rule, unsupported } = parseSignalRule([{ label: 'Supertrend', op: '=', value: 'UP', tone: 'indicator' }]);
expect(rule).toBeNull();
Expand Down Expand Up @@ -185,6 +213,123 @@ describe('strategy preview engine', () => {
});

describe('Basic editor partition preview state', () => {
test('always explains the buy and sell conditions and warns when bars are insufficient', () => {
render(<StrategyPreviewChart
partitionLabel="PARTITION 01"
symbols={['AAPL']}
flows={flowsOf(BUY_BLOCKS, SELL_BLOCKS)}
candles={generatePreviewCandles('AAPL', 1800, 10)}
onClose={() => {}}
/>);

const conditions = screen.getByRole('list', { name: '매수·매도 조건' });
const [buy, sell] = within(conditions).getAllByRole('listitem');
expect(buy).toHaveTextContent('매수');
expect(buy).toHaveTextContent('RSI(14) 30 하향 돌파');
expect(sell).toHaveTextContent('매도');
expect(sell).toHaveTextContent('RSI(14) 70 상향 돌파');
expect(screen.getByRole('status')).toHaveTextContent('신호를 계산하기에 최근 데이터가 부족합니다.');
});

test('recognizes every published Basic condition instead of silently dropping blocks', () => {
const blocks: PreviewBlock[] = [
{ label: '가격 비교', op: '>', value: '전일 종가', tone: 'data' },
{ label: '가격 변화율', op: '상승', base: '전일 종가', value: '1', tone: 'data' },
{ label: '거래량', op: '>', value: '최근 20봉 평균 거래량 2배', tone: 'data' },
{ label: '연속 상승·하락', op: '↑', value: '3봉', tone: 'indicator' },
{ label: '평균선 교차', op: '↑', value: '5봉 · 20봉', tone: 'indicator' },
{ label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' },
{ label: 'MACD 전환', op: '↑', value: '12 · 26 · 9', tone: 'condition' },
{ label: '가격 띠 반전', op: '↑', value: '20봉 · 2σ', tone: 'condition' },
{ label: '현재 수익률', op: '수익', value: '1', tone: 'risk' },
{ label: '보유 기간', op: '≥', value: '5봉', tone: 'risk' },
{ label: '최고 수익률', op: '≥', value: '2', tone: 'risk' },
{ label: '고점 대비 하락', op: '≥', value: '1', tone: 'risk' },
{ label: '정기 매수', value: '매월 첫 거래일', tone: 'time' },
];
const parsed = parseSignalRules(blocks);
expect(parsed.unsupported).toEqual([]);
expect(parsed.rules.map((rule) => rule.kind)).toEqual([
'PRICE', 'PRICE_CHANGE', 'VOLUME_COMPARE', 'STREAK', 'SMA', 'RSI', 'MACD',
'BOLLINGER', 'POSITION_RETURN', 'HOLDING_PERIOD', 'PEAK_RETURN',
'DRAWDOWN_FROM_PEAK', 'SCHEDULE',
]);
});

test('requires every condition in a container instead of using only the first one', () => {
const preview = evaluateStrategyPreview({
symbol: 'AAPL',
flows: [{
id: 'buy-and', label: 'AND 매수', side: 'buy',
blocks: [
{ label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' },
{ label: '가격 변화율', op: '상승', base: '전일 종가', value: '1000', tone: 'data' },
],
}],
});
expect(preview.markers).toEqual([]);
expect(preview.flows[0].description).toContain(' · ');
});

test('fails a whole flow closed when any condition is unsupported', () => {
const preview = evaluateStrategyPreview({
symbol: 'AAPL',
flows: [{
id: 'buy-unsafe', label: '미지원 포함', side: 'buy',
blocks: [
{ label: 'RSI 반등', op: '↑', value: '30', tone: 'condition' },
{ label: 'Supertrend', op: '↑', value: '10', tone: 'indicator' },
],
}],
});
expect(preview.unsupported).toEqual(['Supertrend']);
expect(preview.flows[0].evaluable).toBe(false);
expect(preview.markers).toEqual([]);
});

test('evaluates price, volume, streak, return, holding, peak and drawdown conditions together', () => {
const preview = evaluateStrategyPreview({
symbol: 'AAPL',
candles: candlesFrom([100, 101, 102, 110, 120, 112, 111], [100, 200, 300, 400, 500, 600, 700]),
flows: [
{
id: 'buy-state', label: '상태 매수', side: 'buy', maxExecutions: 1,
blocks: [
{ label: '가격 변화율', op: '상승', base: '전일 종가', value: '0.5', tone: 'data' },
{ label: '거래량', op: '>', value: '이전 봉 거래량', tone: 'data' },
{ label: '연속 상승·하락', op: '↑', value: '2봉', tone: 'indicator' },
],
},
{
id: 'sell-state', label: '상태 매도', side: 'sell', maxExecutions: 1,
blocks: [
{ label: '현재 수익률', op: '수익', value: '1', tone: 'risk' },
{ label: '보유 기간', op: '≥', value: '2봉', tone: 'risk' },
{ label: '최고 수익률', op: '≥', value: '5', tone: 'risk' },
{ label: '고점 대비 하락', op: '≥', value: '3', tone: 'risk' },
],
},
],
});

expect(preview.markers.map((marker) => marker.side)).toEqual(['buy', 'sell']);
expect(preview.markers[0].price).toBe(110);
expect(preview.markers[1].price).toBe(111);
expect(preview.flows.every((flow) => flow.evaluable)).toBe(true);
});

test('does not invent a current-close fill when a signal occurs on the last bar', () => {
const preview = evaluateStrategyPreview({
symbol: 'AAPL',
candles: candlesFrom([100, 101]),
flows: [{
id: 'last-bar', label: '마지막 봉', side: 'buy',
blocks: [{ label: '가격 변화율', op: '상승', base: '전일 종가', value: '0.5', tone: 'data' }],
}],
});
expect(preview.markers).toEqual([]);
});

test('does not invent a graph, signals, or fallback symbols', async () => {
const user = userEvent.setup();
render(<BasicEditor blank goBack={() => {}} />);
Expand Down
27 changes: 27 additions & 0 deletions src/api/strategies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,33 @@ describe('strategy authoring API client', () => {
);
});

it('loads the current valid revisions so reopening an editor preserves launchability', async () => {
const current = {
validationRunId: '21000000-0000-4000-8000-000000000001',
strategyId: document.strategyId,
strategyName: 'Validated strategy',
requestedEditSequence: 3,
semanticHash: document.semanticHash,
elementCatalogVersionId: '0f1a0000-0000-4000-8000-000000000001',
languageVersion: 'basic/v1',
schemaVersion: 'basic-semantic/v1',
catalogVersion: 'basic-elements:2026-08-07',
completedAt: '2026-08-07T12:00:00Z',
};
const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [current] }), { status: 200 }));
const client = createStrategyAuthoringClient({ fetchImpl });

await expect(client.getCurrentValidations!()).resolves.toEqual([expect.objectContaining({
validationRunId: current.validationRunId,
strategyId: document.strategyId,
requestedEditSequence: 3,
semanticHash: document.semanticHash,
})]);
expect(fetchImpl).toHaveBeenCalledWith('/api/v1/strategy-validations/current', expect.objectContaining({
credentials: 'include',
}));
});

it('loads server-owned release inputs and creates an immutable release', async () => {
const inputs = {
executionPolicies: [{
Expand Down
30 changes: 30 additions & 0 deletions src/api/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export interface StrategyValidationResult {
completedAt: string;
}

export interface CurrentStrategyValidation {
validationRunId: string;
strategyId: string;
requestedEditSequence: number;
semanticHash: string;
elementCatalogVersionId: string;
completedAt: string;
}

export interface PreviewStrategyValidationInput {
catalogId: string;
clientRevision: number;
Expand Down Expand Up @@ -126,6 +135,7 @@ export interface StrategyAuthoringClient {
releaseLease(strategyId: string, leaseToken: string, signal?: AbortSignal): Promise<void>;
saveDocument(strategyId: string, input: SaveStrategyDocumentInput, signal?: AbortSignal): Promise<StrategyDocument>;
previewValidation?(strategyId: string, input: PreviewStrategyValidationInput, signal?: AbortSignal): Promise<StrategyValidationResult>;
getCurrentValidations?(signal?: AbortSignal): Promise<CurrentStrategyValidation[]>;
validateStrategy(strategyId: string, catalogId: string, signal?: AbortSignal): Promise<StrategyValidationResult>;
getReleaseInputs(signal?: AbortSignal): Promise<StrategyReleaseInputs>;
releaseStrategy(strategyId: string, input: ReleaseStrategyInput, signal?: AbortSignal): Promise<{ botId: string; backtestLane: string }>;
Expand Down Expand Up @@ -306,6 +316,10 @@ export function createStrategyAuthoringClient({
);
return readValidation(await response.json());
},
async getCurrentValidations(signal) {
const response = await request('/api/v1/strategy-validations/current', 'Current strategy validations', { signal });
return readCurrentValidations(await response.json());
},
async validateStrategy(strategyId, catalogId, signal) {
const response = await request(
`/api/v1/strategies/${encodeURIComponent(strategyId)}/validations`,
Expand Down Expand Up @@ -432,6 +446,22 @@ function readValidation(value: unknown): StrategyValidationResult {
};
}

function readCurrentValidations(value: unknown): CurrentStrategyValidation[] {
const page = object(value, 'Invalid current strategy validations');
if (!Array.isArray(page.items)) throw new Error('Invalid current strategy validation items');
return page.items.map((raw) => {
const item = object(raw, 'Invalid current strategy validation');
return {
validationRunId: string(item.validationRunId, 'validationRunId'),
strategyId: string(item.strategyId, 'strategyId'),
requestedEditSequence: nonNegativeInteger(item.requestedEditSequence, 'requestedEditSequence'),
semanticHash: string(item.semanticHash, 'semanticHash'),
elementCatalogVersionId: string(item.elementCatalogVersionId, 'elementCatalogVersionId'),
completedAt: string(item.completedAt, 'completedAt'),
};
});
}

export function readStrategyReleaseInputs(value: unknown): StrategyReleaseInputs {
const result = object(value, 'Invalid strategy release inputs response');
if (!Array.isArray(result.executionPolicies) || !Array.isArray(result.datasets)) {
Expand Down
Loading
Loading