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
41 changes: 8 additions & 33 deletions src/StrategyApiView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ describe('Strategy API view', () => {
findings: [{ severity: 'BLOCKING_ERROR', code: 'CONDITION_REQUIRED', message: '조건이 필요합니다.' }],
completedAt: '2026-08-01T12:01:00Z',
}),
getReleaseInputs: vi.fn(),
releaseStrategy: vi.fn(),
};
const catalog: BasicStrategyCatalog = {
Expand Down Expand Up @@ -235,7 +234,7 @@ describe('Strategy API view', () => {
heartbeatLease: vi.fn(), releaseLease: vi.fn().mockResolvedValue(undefined),
saveDocument: vi.fn().mockResolvedValue({ ...document, editSequence: 1 }),
validateStrategy: vi.fn().mockRejectedValue(new (await import('./api/strategies')).StrategyApiError(503, 'validation')),
getReleaseInputs: vi.fn(), releaseStrategy: vi.fn(),
releaseStrategy: vi.fn(),
};
const catalogClient: StrategyCatalogClient = {
getBasic: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -353,7 +352,7 @@ describe('Strategy API view', () => {
.mockRejectedValueOnce(new (await import('./api/strategies')).StrategyApiError(409, 'lease'))
.mockResolvedValue({ leaseToken: 'lease-token', expiresAt: '2026-08-08T00:02:00Z' }),
heartbeatLease: vi.fn(), releaseLease: vi.fn().mockResolvedValue(undefined), saveDocument: vi.fn(),
validateStrategy: vi.fn(), getReleaseInputs: vi.fn(), releaseStrategy: vi.fn(),
validateStrategy: vi.fn(), releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

render(<BasicEditor goBack={() => {}} strategyId="reloaded" authoringClient={authoringClient} catalogClient={null} />);
Expand All @@ -379,7 +378,7 @@ describe('Strategy API view', () => {
createBasic: vi.fn(), copyStrategy: vi.fn(), getDocument: vi.fn().mockResolvedValue(document),
acquireLease: vi.fn().mockResolvedValue({ leaseToken: 'pagehide-token', expiresAt: '2026-08-08T00:02:00Z' }),
heartbeatLease: vi.fn(), releaseLease: vi.fn().mockResolvedValue(undefined), saveDocument: vi.fn(),
validateStrategy: vi.fn(), getReleaseInputs: vi.fn(), releaseStrategy: vi.fn(),
validateStrategy: vi.fn(), releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

render(<BasicEditor goBack={() => {}} strategyId="pagehide" authoringClient={authoringClient} catalogClient={null} />);
Expand Down Expand Up @@ -430,25 +429,6 @@ describe('Strategy API view', () => {
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',
};
const authoringClient: StrategyAuthoringClient = {
createBasic: vi.fn(), copyStrategy: vi.fn(), getDocument: vi.fn().mockResolvedValue(document),
acquireLease: vi.fn().mockResolvedValue({ leaseToken: 'lease-token', expiresAt: '2026-08-07T12:02:00Z' }),
Expand All @@ -465,7 +445,6 @@ describe('Strategy API view', () => {
})),
getCurrentValidations: vi.fn().mockResolvedValue([loadedValidation]),
validateStrategy: vi.fn().mockResolvedValue(validation),
getReleaseInputs: vi.fn().mockResolvedValue(releaseInputs),
releaseStrategy: vi.fn().mockResolvedValue({ botId: 'bot-id', backtestLane: 'BASIC' }),
};
const element = (elementCode: string) => ({
Expand Down Expand Up @@ -510,18 +489,17 @@ describe('Strategy API view', () => {
});
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({
await waitFor(() => expect(authoringClient.releaseStrategy).toHaveBeenCalledWith(strategyId, {
validationRunId: validation.validationRunId,
initialCashAmount: '100000.00000000',
datasetManifestId: 'dataset-id', executionPolicyVersion: 'policy-v1',
feePolicyId: 'fee-id', buyingPowerBufferPolicyId: 'buffer-id',
})));
budgetCapBps: 10000,
candidateConflictPolicy: { policy: 'FIRST_WINS' },
}));
expect(onLaunchBot).toHaveBeenCalledWith({ name: '', description: '', botId: 'bot-id' });
});

Expand All @@ -530,7 +508,7 @@ describe('Strategy API view', () => {
createBasic: vi.fn(),
copyStrategy: vi.fn(),
getDocument: vi.fn().mockRejectedValue(new (await import('./api/strategies')).StrategyApiError(404, 'document')),
acquireLease: vi.fn(), heartbeatLease: vi.fn(), releaseLease: vi.fn(), saveDocument: vi.fn(), validateStrategy: vi.fn(), getReleaseInputs: vi.fn(), releaseStrategy: vi.fn(),
acquireLease: vi.fn(), heartbeatLease: vi.fn(), releaseLease: vi.fn(), saveDocument: vi.fn(), validateStrategy: vi.fn(), releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

render(<BasicEditor blank goBack={() => {}} strategyId="missing" authoringClient={authoringClient} catalogClient={null} />);
Expand Down Expand Up @@ -562,7 +540,6 @@ describe('Strategy API view', () => {
releaseLease: vi.fn().mockResolvedValue(undefined),
saveDocument: vi.fn(),
validateStrategy: vi.fn(),
getReleaseInputs: vi.fn(),
releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

Expand Down Expand Up @@ -595,7 +572,6 @@ describe('Strategy API view', () => {
releaseLease: vi.fn().mockResolvedValue(undefined),
saveDocument: vi.fn(),
validateStrategy: vi.fn(),
getReleaseInputs: vi.fn(),
releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

Expand Down Expand Up @@ -626,7 +602,6 @@ describe('Strategy API view', () => {
releaseLease: vi.fn().mockResolvedValue(undefined),
saveDocument: vi.fn(),
validateStrategy: vi.fn(),
getReleaseInputs: vi.fn(),
releaseStrategy: vi.fn(),
} as StrategyAuthoringClient;

Expand Down
28 changes: 6 additions & 22 deletions src/api/strategies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,37 +207,21 @@ describe('strategy authoring API client', () => {
}));
});

it('loads server-owned release inputs and creates an immutable release', async () => {
const inputs = {
executionPolicies: [{
version: 'policy-v1', brokerRulesVersion: 'market-v1', accountingRulesVersion: 'accounting-v1',
precisionRulesVersion: 'precision-v1', feePolicyId: 'fee-id', feeRateBps: 20,
buyingPowerBufferPolicyId: 'buffer-id', buyingPowerBufferBps: 1,
}],
datasets: [{
id: 'dataset-id', feedCode: 'alpaca-sip', dataLayer: 'ADJUSTED', resolution: '1m',
periodStart: '2025-01-01', periodEnd: '2026-01-01', schemaVersion: 'market-bars-v2',
}],
observedAt: '2026-08-07T12:00:00Z',
};
it('creates an immutable release without client-selected policy or datasets', async () => {
const release = {
validationRunId: 'validation-id', initialCashAmount: '100000', budgetCapBps: 10000,
brokerRulesVersion: 'market-v1', accountingRulesVersion: 'accounting-v1',
precisionRulesVersion: 'precision-v1', feePolicyId: 'fee-id',
buyingPowerBufferPolicyId: 'buffer-id', datasetManifestId: 'dataset-id',
executionPolicyVersion: 'policy-v1', candidateConflictPolicy: { policy: 'FIRST_WINS' },
candidateConflictPolicy: { policy: 'FIRST_WINS' },
};
const fetchImpl = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(inputs), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ botId: 'bot-id', backtestLane: 'BASIC' }), { status: 201 }));
const fetchImpl = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ botId: 'bot-id', backtestLane: 'BASIC' }), { status: 201 }),
);
const client = createStrategyAuthoringClient({ fetchImpl });

await expect(client.getReleaseInputs()).resolves.toEqual(inputs);
await expect(client.releaseStrategy(document.strategyId, release)).resolves.toEqual({ botId: 'bot-id', backtestLane: 'BASIC' });
expect(fetchImpl.mock.calls.map(([url, init]) => [url, (init as RequestInit).method ?? 'GET'])).toEqual([
['/api/v1/strategy-release-inputs', 'GET'],
[`/api/v1/strategies/${document.strategyId}/releases`, 'POST'],
]);
expect(JSON.parse((fetchImpl.mock.calls[0][1] as RequestInit).body as string)).toEqual(release);
});

it('exposes conflict status without leaking a lease token', async () => {
Expand Down
12 changes: 0 additions & 12 deletions src/api/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,6 @@ export interface ReleaseStrategyInput {
validationRunId: string;
initialCashAmount: string;
budgetCapBps: number;
brokerRulesVersion: string;
accountingRulesVersion: string;
precisionRulesVersion: string;
feePolicyId: string;
buyingPowerBufferPolicyId: string;
datasetManifestId: string;
executionPolicyVersion: string;
candidateConflictPolicy: Record<string, unknown>;
}

Expand All @@ -137,7 +130,6 @@ export interface StrategyAuthoringClient {
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 @@ -328,10 +320,6 @@ export function createStrategyAuthoringClient({
);
return readValidation(await response.json());
},
async getReleaseInputs(signal) {
const response = await request('/api/v1/strategy-release-inputs', 'Strategy release inputs', { signal });
return readStrategyReleaseInputs(await response.json());
},
async releaseStrategy(strategyId, input, signal) {
const response = await request(
`/api/v1/strategies/${encodeURIComponent(strategyId)}/releases`,
Expand Down
45 changes: 9 additions & 36 deletions src/views/StrategyViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from '../lib/strategyCanvasLayout';
import type { CanvasPoint, CanvasSize, CardMoveGesture } from '../lib/strategyCanvasLayout';
import { defaultStrategyAuthoringClient, defaultStrategyCatalogClient, defaultStrategyLibraryClient, STRATEGY_LIBRARY_PAGE_SIZE, StrategyApiError } from '../api/strategies';
import type { BasicCatalogInstrument, BasicStrategyCatalog, StrategyAuthoringClient, StrategyCatalogClient, StrategyLibraryClient, StrategyLibraryItem, StrategyReleaseInputs, StrategyValidationResult } from '../api/strategies';
import type { BasicCatalogInstrument, BasicStrategyCatalog, StrategyAuthoringClient, StrategyCatalogClient, StrategyLibraryClient, StrategyLibraryItem, StrategyValidationResult } from '../api/strategies';

type EditorMode = 'basic' | 'pro';
type EditorLoadFailure = 'sign-in' | 'missing' | 'conflict' | 'transport' | 'unreadable';
Expand Down Expand Up @@ -1675,8 +1675,6 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [botName, setBotName] = useState('');
const [botDescription, setBotDescription] = useState('');
const [releaseInputs, setReleaseInputs] = useState<StrategyReleaseInputs | null>(null);
const [releaseInputsPending, setReleaseInputsPending] = useState(false);
const [releasePending, setReleasePending] = useState(false);
const [releaseError, setReleaseError] = useState<string | null>(null);
const [initialCashAmount, setInitialCashAmount] = useState('100000');
Expand Down Expand Up @@ -2352,7 +2350,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
setReleaseError(null);
};

const preparePersonalBotLaunch = async () => {
const preparePersonalBotLaunch = () => {
if (!isLaunchable) {
const firstIssue = validationIssues[0]?.message
?? (serverValidation?.status === 'INVALID'
Expand All @@ -2374,32 +2372,15 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
setSaveFeedback(null);
setLaunchDialogOpen(true);
setReleaseError(null);
if (!strategyId || !authoringClient) {
setAnnouncement('개인 운용 봇 정보를 입력해 주세요.');
return;
}
setReleaseInputsPending(true);
setReleaseInputs(null);
try {
const inputs = await authoringClient.getReleaseInputs();
setReleaseInputs(inputs);
if (inputs.executionPolicies.length === 0 || inputs.datasets.length === 0) {
setReleaseError('현재 봇을 출시할 수 없습니다. 잠시 후 다시 시도해 주세요.');
}
setAnnouncement('봇 출시 준비를 마쳤습니다.');
} catch {
setReleaseError('봇 출시를 준비하지 못했습니다. 잠시 후 다시 시도해 주세요.');
} finally {
setReleaseInputsPending(false);
}
setAnnouncement(strategyId && authoringClient
? '봇 출시 준비를 마쳤습니다.'
: '개인 운용 봇 정보를 입력해 주세요.');
};

const launchPersonalBot = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (strategyId && authoringClient) {
const policy = releaseInputs?.executionPolicies[0];
const dataset = releaseInputs?.datasets[0];
if (!savedValidation || savedValidation.status !== 'VALID' || !policy || !dataset) return;
if (!savedValidation || savedValidation.status !== 'VALID') return;
const cash = fixedScaleUsdAmount(initialCashAmount);
const budget = Number(budgetPercent);
if (!cash || !Number.isFinite(budget) || budget <= 0 || budget > 100) {
Expand All @@ -2413,13 +2394,6 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
validationRunId: savedValidation.validationRunId,
initialCashAmount: cash,
budgetCapBps: Math.round(budget * 100),
brokerRulesVersion: policy.brokerRulesVersion,
accountingRulesVersion: policy.accountingRulesVersion,
precisionRulesVersion: policy.precisionRulesVersion,
feePolicyId: policy.feePolicyId,
buyingPowerBufferPolicyId: policy.buyingPowerBufferPolicyId,
datasetManifestId: dataset.id,
executionPolicyVersion: policy.version,
candidateConflictPolicy: { policy: 'FIRST_WINS' },
});
setLaunchDialogOpen(false);
Expand Down Expand Up @@ -4133,9 +4107,8 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
</header>
<form onSubmit={(event) => { void launchPersonalBot(event); }}>
{strategyId && authoringClient ? <>
{releaseInputsPending && <LoadingState label="봇 출시를 준비하는 중입니다." />}
{releaseError && <ErrorState title={releaseError} />}
{!releaseInputsPending && releaseInputs && <>
<>
<label className="personal-bot-launch-field">
<span><strong>초기 운용 자금</strong><small>USD</small></span>
<input autoFocus type="number" min="1" step="0.01" aria-label="초기 운용 자금" value={initialCashAmount} onChange={(event) => setInitialCashAmount(event.target.value)} />
Expand All @@ -4144,7 +4117,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
<span><strong>전략 운용 예산</strong><small>%</small></span>
<input type="number" min="0.01" max="100" step="0.01" aria-label="전략 운용 예산 비율" value={budgetPercent} onChange={(event) => setBudgetPercent(event.target.value)} />
</label>
</>}
</>
</> : <>
<label className="personal-bot-launch-field">
<span><strong>봇 이름</strong><small>{botName.length}/40</small></span>
Expand Down Expand Up @@ -4181,7 +4154,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
kind="primary"
icon={Rocket}
disabled={strategyId && authoringClient
? releaseInputsPending || releasePending || !releaseInputs?.executionPolicies[0] || !releaseInputs?.datasets[0]
? releasePending
: !botName.trim() || !botDescription.trim()}
>{releasePending ? '출시 중…' : '봇 출시하기'}</Button>
</footer>
Expand Down
Loading