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
58 changes: 52 additions & 6 deletions src/StrategyApiView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ describe('Strategy API view', () => {
expect(screen.queryByText('전략을 저장하지 못했습니다.')).not.toBeInTheDocument();
});

/* The published catalog is several hundred instruments. A plain dropdown listed
every one of them at once, which is unreadable and unscannable. */
test('keeps the instrument picker short and searchable for a large catalog', async () => {
/* The published catalog is several hundred instruments. Keep the picker viewport
compact and scrollable without truncating the selected alphabet group. */
test('shows every matching instrument in a large catalog', async () => {
const user = userEvent.setup();
const catalog: BasicStrategyCatalog = {
version: {
Expand All @@ -276,10 +276,17 @@ describe('Strategy API view', () => {
await waitFor(() => expect(catalogClient.getBasic).toHaveBeenCalledWith(expect.any(AbortSignal)));
await user.click(screen.getByRole('button', { name: 'PARTITION 01 종목 관리' }));

expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option')).toHaveLength(50);
expect(screen.getByText(/270개는 표시하지 않았습니다/)).toBeInTheDocument();
const listbox = screen.getByRole('listbox', { name: '추가할 종목' });
expect(within(listbox).getAllByRole('option')).toHaveLength(320);
expect(screen.queryByText(/표시하지 않았습니다/)).not.toBeInTheDocument();

// Searching is the way to the other 270, and Enter takes the top match.
await user.click(screen.getByRole('button', { name: 'S' }));
expect(within(listbox).getAllByRole('option')).toHaveLength(320);

await user.click(screen.getByRole('button', { name: 'ALL' }));
expect(within(listbox).getAllByRole('option')).toHaveLength(320);

// Searching still narrows the complete set, and Enter takes the top match.
await user.type(screen.getByLabelText('종목 검색'), 'SYM31');
await waitFor(() => expect(
within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option'),
Expand All @@ -288,6 +295,45 @@ describe('Strategy API view', () => {
expect(screen.getByRole('dialog')).toHaveTextContent('SYM310');
});

test('filters the instrument picker by ticker initial before scrolling', async () => {
const user = userEvent.setup();
const catalog: BasicStrategyCatalog = {
version: {
id: 'catalog-id', languageVersion: 'basic/v1', schemaVersion: 'schema/v1', catalogVersion: 'catalog/v1',
dataRequirementVersion: 'data/v1', definitionHash: 'catalog-hash', publishedAt: '2026-08-01T12:00:00Z', retiredAt: null,
},
elements: [],
features: [],
instruments: [
{ id: 'aapl-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'AAPL' },
{ id: 'amd-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'AMD' },
{ id: 'meta-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'META' },
{ id: 'msft-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'MSFT' },
{ id: 'spy-id', assetType: 'ETF', primaryExchangeMic: 'ARCX', currencyCode: 'USD', symbol: 'SPY' },
],
};
const catalogClient: StrategyCatalogClient = { getBasic: vi.fn().mockResolvedValue(catalog) };
render(<BasicEditor blank goBack={() => {}} catalogClient={catalogClient} />);
await waitFor(() => expect(catalogClient.getBasic).toHaveBeenCalledWith(expect.any(AbortSignal)));
await user.click(screen.getByRole('button', { name: 'PARTITION 01 종목 관리' }));

const alphabetFilter = screen.getByRole('group', { name: '종목 알파벳 필터' });
expect(within(alphabetFilter).getAllByRole('button')).toHaveLength(27);

await user.click(within(alphabetFilter).getByRole('button', { name: 'A' }));
expect(within(alphabetFilter).getByRole('button', { name: 'A' })).toHaveAttribute('aria-pressed', 'true');
expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option').map((option) => option.textContent)).toEqual([
'AAPLSTOCK · XNAS',
'AMDSTOCK · XNAS',
]);

await user.click(within(alphabetFilter).getByRole('button', { name: 'M' }));
expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option').map((option) => option.textContent)).toEqual([
'METASTOCK · XNAS',
'MSFTSTOCK · XNAS',
]);
});

test('retries a transient lease conflict left by a page navigation', async () => {
const document: StrategyDocument = {
strategyId: 'reloaded',
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,7 @@ const english: Record<string, string> = {
'연결 상태를 확인한 뒤 다시 시도해 주세요.': 'Check your connection and try again.',
'연결 상태를 확인한 뒤 다시 시도해 주세요. 확인되지 않은 편집 내용은 표시하지 않습니다.': 'Check your connection and try again. Unverified editing content is not shown.',
'추가할 종목': 'Symbol to add',
'종목 알파벳 필터': 'Symbol alphabet filter',
'종목 미선택': 'No symbol selected',
'실제 시장 데이터 기반 미리보기만 표시합니다.': 'Only previews backed by real market data are shown.',
'현재 미리보기 데이터 계약이 연결되지 않아 차트와 거래 신호를 표시하지 않습니다. 저장된 종목과 전략 규칙은 편집 화면에서 계속 확인할 수 있습니다.': 'The preview data contract is not connected, so no chart or trade signals are shown. You can still review saved symbols and strategy rules in the editor.',
Expand Down
37 changes: 37 additions & 0 deletions src/styles/balanced.css
Original file line number Diff line number Diff line change
Expand Up @@ -6645,6 +6645,43 @@
}
.symbol-manager-picker { display: grid; gap: 8px; }
.symbol-manager-picker-actions { display: flex; justify-content: flex-end; gap: 8px; }
.symbol-manager-alphabet {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 4px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
}
.symbol-manager-alphabet button {
min-width: 0;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
color: var(--text-faint);
background: transparent;
font: 800 9px var(--font-mono);
cursor: pointer;
}
.symbol-manager-alphabet button:hover:not(:disabled) {
color: var(--text);
background: var(--surface-3);
}
.symbol-manager-alphabet button.is-active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--line-strong));
color: var(--accent);
background: var(--accent-soft);
}
.symbol-manager-alphabet button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.symbol-manager-alphabet button:disabled {
opacity: .3;
cursor: default;
}
.symbol-manager-results-head {
display: flex;
align-items: baseline;
Expand Down
50 changes: 37 additions & 13 deletions src/views/StrategyViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,11 +718,11 @@ const BLOCK_LIBRARY: BlockLibraryCategory[] = [

const BASIC_FAVORITE_BLOCKS_STORAGE_KEY = 'i2s-basic-editor-favorite-blocks-v1';
const LOCAL_PREVIEW_SYMBOLS = ['AAPL', 'MSFT', 'SPY', 'NVDA', 'QQQ'];
const INSTRUMENT_INITIALS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
/* The local preview list carries no exchange or asset type, so those stay optional
and the picker simply omits the second line when the catalog is absent. */
type SelectableInstrument = Pick<BasicCatalogInstrument, 'id' | 'symbol'>
& Partial<Pick<BasicCatalogInstrument, 'assetType' | 'primaryExchangeMic'>>;
const INSTRUMENT_RESULT_LIMIT = 50;
const getLibraryBlockTone = (label: string): BlockTone => (
BLOCK_LIBRARY.find((category) => category.items.includes(label))?.tone ?? 'neutral'
);
Expand Down Expand Up @@ -1666,6 +1666,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
const validationPreviewRevisionRef = useRef(0);
const [pendingInstrumentKey, setPendingInstrumentKey] = useState('');
const [instrumentQuery, setInstrumentQuery] = useState('');
const [instrumentInitial, setInstrumentInitial] = useState<string | null>(null);
// Two-phase dismissal so the toast can slide back down (mirroring its entry)
// instead of vanishing instantly.
const [saveFeedbackClosing, setSaveFeedbackClosing] = useState(false);
Expand Down Expand Up @@ -3609,17 +3610,27 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
? []
: LOCAL_PREVIEW_SYMBOLS.map((symbol) => ({ id: '', symbol }));
const normalizedInstrumentQuery = instrumentQuery.trim().toLocaleUpperCase('en-US');
const availableInstruments = selectableInstruments.filter((instrument) => !managedSymbols.includes(instrument.symbol)
&& (!normalizedInstrumentQuery || instrument.symbol.toLocaleUpperCase('en-US').includes(normalizedInstrumentQuery)));
/* The published catalog runs to several hundred instruments. Rendering them all
turns the picker into an unreadable wall, so the list stays short and the
search box is the way through it. */
const visibleInstruments = availableInstruments.slice(0, INSTRUMENT_RESULT_LIMIT);
const hiddenInstrumentCount = availableInstruments.length - visibleInstruments.length;
const unselectedInstruments = selectableInstruments.filter((instrument) => !managedSymbols.includes(instrument.symbol));
const availableInstrumentInitials = new Set(unselectedInstruments.map((instrument) => (
instrument.symbol.toLocaleUpperCase('en-US').charAt(0)
)));
const availableInstruments = unselectedInstruments.filter((instrument) => {
const normalizedSymbol = instrument.symbol.toLocaleUpperCase('en-US');
return (!instrumentInitial || normalizedSymbol.startsWith(instrumentInitial))
&& (!normalizedInstrumentQuery || normalizedSymbol.includes(normalizedInstrumentQuery));
});
/* The viewport stays compact and scrollable, while every match remains available
within the selected alphabet group or search result. */
const visibleInstruments = availableInstruments;
const selectedInstrument = availableInstruments.find((instrument) => (instrument.id || instrument.symbol) === pendingInstrumentKey)
?? visibleInstruments[0];
const instrumentKey = (instrument: SelectableInstrument) => instrument.id || instrument.symbol;
const selectedInstrumentKey = selectedInstrument ? instrumentKey(selectedInstrument) : '';
const selectInstrumentInitial = (initial: string | null) => {
setInstrumentInitial(initial);
setInstrumentQuery('');
setPendingInstrumentKey('');
};
const moveInstrumentSelection = (delta: number) => {
if (visibleInstruments.length === 0) return;
const current = visibleInstruments.findIndex((instrument) => instrumentKey(instrument) === selectedInstrumentKey);
Expand Down Expand Up @@ -3849,7 +3860,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
<button className="section-move-handle" data-testid={`${section.id}-move-handle`} aria-label={`PARTITION ${sectionNumber} 이동`} onPointerDown={(event) => beginSectionMove(event, section)}><GripVertical size={16} /></button>
<div className="section-identity"><span>PARTITION {sectionNumber}</span><strong>{section.symbol}</strong><small>매수 {section.cards.buy.length} · 매도 {section.cards.sell.length}</small></div>
<div className="section-settings">
<label><span className="section-setting-caption" data-testid="partition-setting-caption" title="거래 종목">종목</span><button type="button" className="section-symbol-manager" aria-label={`PARTITION ${sectionNumber} 종목 관리`} onClick={() => { setPendingInstrumentKey(''); setInstrumentQuery(''); setSymbolManagerSectionId(section.id); }}><strong>{splitPartitionSymbols(section.symbol).length || 0}개 종목</strong><small>한도 설정</small></button></label>
<label><span className="section-setting-caption" data-testid="partition-setting-caption" title="거래 종목">종목</span><button type="button" className="section-symbol-manager" aria-label={`PARTITION ${sectionNumber} 종목 관리`} onClick={() => { setPendingInstrumentKey(''); setInstrumentQuery(''); setInstrumentInitial(null); setSymbolManagerSectionId(section.id); }}><strong>{splitPartitionSymbols(section.symbol).length || 0}개 종목</strong><small>한도 설정</small></button></label>
<label><span className="section-setting-caption" data-testid="partition-setting-caption" title="전체 전략 대비 예산">예산</span><span className="section-allocation"><input type="number" min=".1" max="100" step=".1" aria-label={`PARTITION ${sectionNumber} 전체 전략 대비 예산`} value={section.allocation} onWheel={(event) => event.stopPropagation()} onChange={(event) => updateSection(section.id, { allocation: Number(event.target.value) })} /><b>%</b></span></label>
<label><span className="section-setting-caption" data-testid="partition-setting-caption" title="기본 봉 주기">봉 주기</span><select aria-label={`PARTITION ${sectionNumber} 기본 봉 주기`} value={section.timeframe} onChange={(event) => updateSection(section.id, { timeframe: event.target.value })}>{BASIC_TIMEFRAMES.map((timeframe) => <option key={timeframe}>{timeframe}</option>)}</select></label>
</div>
Expand Down Expand Up @@ -4003,14 +4014,30 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
aria-controls="symbol-manager-results"
aria-activedescendant={selectedInstrument ? `symbol-option-${selectedInstrumentKey}` : undefined}
value={instrumentQuery}
onChange={(event) => { setInstrumentQuery(event.target.value); setPendingInstrumentKey(''); }}
onChange={(event) => { setInstrumentQuery(event.target.value); setInstrumentInitial(null); setPendingInstrumentKey(''); }}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') { event.preventDefault(); moveInstrumentSelection(1); }
else if (event.key === 'ArrowUp') { event.preventDefault(); moveInstrumentSelection(-1); }
else if (event.key === 'Enter' && selectedInstrument) { event.preventDefault(); addManagedSymbol(); }
}}
/>
</label>
<div className="symbol-manager-alphabet" role="group" aria-label="종목 알파벳 필터">
<button
type="button"
className={!instrumentInitial ? 'is-active' : ''}
aria-pressed={!instrumentInitial}
onClick={() => selectInstrumentInitial(null)}
>ALL</button>
{INSTRUMENT_INITIALS.map((initial) => <button
key={initial}
type="button"
className={instrumentInitial === initial ? 'is-active' : ''}
aria-pressed={instrumentInitial === initial}
disabled={!availableInstrumentInitials.has(initial)}
onClick={() => selectInstrumentInitial(initial)}
>{initial}</button>)}
</div>
<div className="symbol-manager-results-head">
<span>추가할 종목</span>
<small>{availableInstruments.length}개 선택 가능</small>
Expand Down Expand Up @@ -4038,9 +4065,6 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
</li>;
})}
</ul>}
{hiddenInstrumentCount > 0 && <small className="symbol-manager-results-more" role="status">
{hiddenInstrumentCount}개는 표시하지 않았습니다. 티커를 입력해 좁혀 주세요.
</small>}
{!catalogError && basicCatalog && normalizedInstrumentQuery && availableInstruments.length === 0 && <small className="symbol-manager-results-empty" role="status">일치하는 공식 지원 종목이 없습니다.</small>}
{!catalogError && basicCatalog && !normalizedInstrumentQuery && availableInstruments.length === 0 && <small className="symbol-manager-results-empty" role="status">추가할 수 있는 종목을 모두 담았습니다.</small>}
</div>
Expand Down
Loading