diff --git a/MBN_STOCK_WEBVIEW.sln b/MBN_STOCK_WEBVIEW.sln
index 29ec317..6c72f10 100644
--- a/MBN_STOCK_WEBVIEW.sln
+++ b/MBN_STOCK_WEBVIEW.sln
@@ -38,6 +38,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBri
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyWeb.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj", "{9333727F-7367-4AF8-BA9D-EF159A3148D9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbWriteSmoke", "tools\MBN_STOCK_WEBVIEW.DbWriteSmoke\MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj", "{76A4348A-237C-4634-9861-43102CBA24D6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests", "tests\MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests\MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests.csproj", "{263E151C-50C1-4B6B-A219-4013273D19AF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -168,6 +172,22 @@ Global
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.ActiveCfg = Release|Any CPU
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.Build.0 = Release|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.Build.0 = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.Build.0 = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.ActiveCfg = Release|Any CPU
+ {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.Build.0 = Release|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.Build.0 = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.Build.0 = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.ActiveCfg = Release|Any CPU
+ {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -187,6 +207,8 @@ Global
{60A364DD-1283-4DA1-8660-791B5CB4F86F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{8F28CB75-54A4-4C27-A7F8-88A30D485B19} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{9333727F-7367-4AF8-BA9D-EF159A3148D9} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {76A4348A-237C-4634-9861-43102CBA24D6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
+ {263E151C-50C1-4B6B-A219-4013273D19AF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}
diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md
index 75b3a76..58ad9f4 100644
--- a/docs/LEGACY_FEATURE_AUDIT.md
+++ b/docs/LEGACY_FEATURE_AUDIT.md
@@ -1,148 +1,149 @@
# 원본 기능 재감사 기준표
-감사 기준일: 2026-07-12
+감사 기준일: 2026-07-15
+
원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N`
+
마이그레이션 대상: `C:\Users\MD\source\repos\MBN_STOCK_WEBVIEW`
-이 문서는 원본 WinForms/FarPoint 프로그램과 현재 WinUI 3/WebView2 프로그램을 다시 대조한 결과다. 원본 저장소와 실제 Cuts는 읽기 전용으로 조사했다. 자동 테스트나 UI 코드가 존재한다는 사실과 실제 운영 환경에서 동등성이 확인됐다는 사실을 구분한다.
+## 감사 결론
+
+현재 실행 대상은 `.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2` 기반
+`MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 이는 웹사이트가 아니라 로컬 Web 자산을
+WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다.
+
+초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write
+없음”은 현재 상태가 아니다. 원본 UI와 클릭 이벤트는 LegacyParityApp → strict bridge →
+C# workflow로 연결됐고, 개발 DB write 9개 시나리오와 설치된 MSIX의 개발 PGM 최종
+통합까지 검증했다. 기본 송출 모드는 계속 `DryRun`이다.
## 상태 정의
| 상태 | 의미 |
|---|---|
-| `미적용` | 원본 동작의 대응 구현 또는 필수 검증 경로가 아직 없다. |
-| `적용 중` | 현재 작업 트리에 구현 또는 검사 보강이 있으나 최종 패키지·실환경 검증까지 끝나지 않았다. |
-| `완료` | 현재 코드와 해당 자동 검증에 반영됐고 이번 재감사에서 완료로 판정했다. |
-| `외부자산 필요` | 코드만으로 완료할 수 없으며 Git/MSIX 밖의 승인된 자산이나 운영 환경이 필요하다. |
-
-완료 수는 증분 포팅과 최종 회귀 결과에 따라 바뀌므로 아래 표를 단일 기준으로 사용한다. 기존 문서에서 사용하는 `완료`, `통합 완료`, `전체 통과` 표현도 아래 상태와 검증 범위로 다시 해석해야 한다.
+| `완료` | 현재 LegacyParityApp 코드와 해당 자동/통합 검증에 반영됨 |
+| `외부자산 필요` | 코드로 만들 수 없는 Git/MSIX 밖의 승인 영상·배경이 제공되지 않음 |
+| `외부 데이터 정합성` | 현재 DB view/data가 원본 규칙의 fresh identity를 공급하지 않아 추정 없이 차단함 |
## 우선순위별 결과
-| ID | 우선순위 | 기능·누락 후보 | 상태 | 근거 파일·메서드 | 현재 판정 | 검증 방법 |
-|---|---|---|---|---|---|---|
-| LF-001 | P0 | `s6001` 해외지수 영상 자산 | 외부자산 필요 | 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:491-513`, `S6001SceneMutationBuilder.Metadata`/`Index`; 검증기 `src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs:375-406`, `ResolveAssetPath` | 해외지수 15개 target이 13개 `Video\20201008_<국가>.vrv`에 의존한다. 감사 시 승인 Cuts의 `Video` 폴더는 비어 있어 이 target들은 DryRun PREPARE에서도 COM 호출 전에 차단된다. 유가·금의 PNG/JPG는 존재한다. | 승인된 파일을 Cuts root 아래에 제공한 뒤 강화된 cut/asset 검사와 s6001 각 target DryRun PREPARE를 실행한다. 실제 PGM은 별도 승인 회차에서만 확인한다. |
-| LF-002 | P0 | cut coverage의 장면 종속 asset 검사 | 완료 | `scripts/LegacyCutRequirements.json`, `LegacyCutCoverage.psm1`, `Test-LegacyCutCoverage.ps1`; 테스트 `tests/Scripts/Test-LegacyCutCoverage.Tests.ps1`, `LegacyCutRequirementManifestTests.cs` | 45개 active alias와 23개 장면 종속 asset을 분리해 존재·0바이트·root escape·reparse·manifest 중복을 검사한다. 완전/누락/0바이트/reparse/escape fixture가 자동 테스트에 연결됐다. 실제 승인 Cuts read-only 실행은 alias missing/unsafe 0, asset unsafe 0, asset missing 14로 정확히 실패했다: `s5006` 1개와 `s6001` 13개 영상이다. | 실제 누락 14개가 승인 Cuts에 제공되면 같은 명령을 다시 실행해 `Status=Passed`를 확인한다. 검사기 완료와 외부 자산 준비 완료는 LF-001/LF-003에서 분리한다. |
-| LF-003 | P0 | `s5006` 큐브 배경 영상 | 외부자산 필요 | 원본 `MBN_STOCK_N/Scene/s5006.cs:30-31`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:31-48`; `docs/PLAYOUT_OPERATIONS.md:81-85` | `Video\큐브배경.vrv`가 승인 Cuts에 없다. 기존 운영 문서에는 누락이 기록돼 있으나 파일 제공 전에는 `s5006` PREPARE가 완료된 것이 아니다. | asset 검사 통과 후 s5006 DryRun PREPARE, 승인된 Test/PGM 회차 순으로 검증한다. |
-| LF-004 | P0 | 실제 운영 DB write | 미적용 | `docs/OPERATOR_UI_PARITY.md:48-62,167-170,214-218,229-246,278`; GraphE `MainWindow.ManualFinancial.cs`; PList `MainWindow.NamedPlaylists.cs`; ThemeA/EList `MainWindow.OperatorCatalogs.cs` | mutation 계약, fake executor, rollback·OutcomeUnknown 테스트는 존재하지만 GraphE, PList/AList, ThemeA, EList의 운영 Oracle write는 실행하지 않았다. FSell/VI도 격리 저장소 write만 검증했다. 따라서 운영 데이터 쓰기 동등성은 미검증이다. | 승인된 테스트용 식별자와 사전 snapshot을 정하고 create/update/delete를 각 1회 실행한다. 매 단계 후 별도 read-only 조회로 결과를 대조하고, timeout·OutcomeUnknown이면 반복하지 않는다. 운영 데이터에는 임의로 실행하지 않는다. |
-| LF-005 | P0 | 전체 장면·운영 UI의 실제 Tornado2/PGM 검증 | 미적용 | `docs/MIGRATION.md:69-74`; `docs/OPERATOR_UI_PARITY.md:48-62`; `docs/PLAYOUT.md:348-366` | 승인된 실제 회차의 범위는 5001/5074와 그 Page/playlist NEXT, refresh, TAKE OUT이다. 자동 builder/DB smoke 결과를 나머지 장면의 PGM 동등성으로 확대할 수 없다. | 장면별 허용 자산과 DB 전제조건을 먼저 통과시킨다. 이후 회차별 계획 SHA-256과 명시적 승인을 받아 Network Monitoring 명령, callback, PGM 화면을 함께 기록한다. |
-| LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. |
-| LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. |
-| LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. |
-| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`; KRX/해외종목/manual 전용 restore와 테스트; 상세 `docs/NAMED_PLAYLIST_READ_AUDIT.md` | 실제 Oracle 활성 정의 23개와 2,213행 모두 정확히 7필드이고 원문 재결합, 한국어 제목·필드, enabled, page 공백 제거 및 typed persistence가 일치했다. 기본 감사는 KRX 94행 current-code remap과 해외종목 78/78을 포함해 `selectionMapped=1,581`, 차단 632다. 별도 GraphE fresh 감사가 manual 36행 중 22행을 materialize하고 저장값 무효 9행·identity 중복 5행을 차단해 두 증거의 통합 결과는 `selectionMapped=1,603`, 차단 610이다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니다. | 집중 테스트, Release 실제 read-only DB 감사와 전체 `REAL_DB_SMOKE: PASS`. FSell/VI PList 행은 0건이지만 운영 production read는 FSell 3×5행, VI 9항목·2 page이고 파일 전후 불변이다. raw 값·인증정보 출력, DB write, Tornado/PGM 명령은 0건이다. 남은 KRX/NXT/live-data·GraphE identity/data·unique mapper 차단을 해소하고 scene/asset/package/PGM gate를 별도로 통과하기 전에는 전체 복원 완료로 올리지 않는다. |
-| LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산 필요 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경`과 `기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,377/1,377, Playout 387/387과 VS2026 앱 Debug/Release x64 build를 통과했다. signed 1.0.4 package는 누락 상태를 별도 배경 상태로 표시한다. 자산 제공 뒤 F2/F3와 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. |
-| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 완료 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs`; 실행 증거 `docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md` | 원본 CP949 358바이트·8행·9필드와 SHA-256 `1EE76BC4…BFC0D2F2`를 고정하고, 고정 read-only source·strict parser·현재 Oracle/MariaDB exact identity·전체 원자 저장·one-time marker를 적용했다. 설치된 signed 1.0.3 package에서 빈 상태 `0 PAIRS`를 확인한 뒤 명시적 버튼을 정확히 한 번 실행해 8쌍을 저장했다. 정상 종료·재시작 후에도 `8 PAIRS`, 원본 순서의 8행과 완료형 `원본 8쌍 가져옴` gate가 유지됐고, 원본 파일 SHA-256은 불변이었다. 원본·DB·Tornado에는 쓰기나 명령을 보내지 않았다. | Core 20/20, Web 12/12, 실제 read-only DB identity 8/8, package 0→8 및 재시작 marker 영속성 PASS. 손상·동명이름·기존쌍 중복·부분 실패는 계속 전체 fail closed이며 package family data를 초기화해 같은 회차를 재현하지 않는다. |
-| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; importer `LegacyManualOperatorDataImport`, `LegacyManualOperatorDataImporter`; production read `S5025TrustedManualFileDataSource`, `ViTrustedManualFileStore`; 감사 `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`, `docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md` | 고정 원본 경로·strict CP949·source/hash 재검증·no-overwrite staged commit·marker-last·OutcomeUnknown quarantine를 유지한다. 격리 fixture는 FSell 15행·VI 9건, aggregate SHA-256 `fd39a363…a2114`, 첫 실행 성공과 두 번째 `AlreadyImported`를 확인했다. 운영 profile은 이미 원본과 byte-identical한 4파일이 있어 importer가 `DestinationNotEmpty`로 차단되며 marker/intent를 만들지 않는다. production opened-handle read는 FSell 3 audience 각각 5행, VI 9항목·2 page와 안정된 두 번째 version을 확인했고 감사 전후 4파일은 불변이다. 현재 PList에는 FSell/VI 행이 0건이다. | 새 빈 signed package profile의 명시적 UI importer 검증은 남아 있다. 현재 운영 profile의 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. PList에 향후 FSell/VI 행이 생기면 같은 fresh source/version gate를 거치며 scene data·asset·PREPARE·PGM은 별도 검증한다. |
-| LF-013 | P1 | generic native `bridge-error` 표시 | 완료 | producer `MainWindow.xaml.cs`, `MainWindow.Playout.cs`; consumer `Web/app.js` `handleBridgeError`/`handleNativeMessage`; 테스트 `operator-ui-app-integration.test.cjs` | bounded toast/log로 소비하며 malformed status/quarantine payload에서 playout state를 변경하지 않는다. 전체 Web 413/413과 signed 1.0.4 패키지 반영을 확인했다. | 새 native error producer를 추가할 때마다 같은 bounded consumer 계약을 유지한다. |
-| LF-014 | P2 | 영구 운영 로그 | 완료 | 원본 `MBN_STOCK_N/MainForm.cs:84-180`; 현재 `NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 폐쇄 event/state만 기록하며 4MiB/31일, 중복 억제, reparse/handle 검증, single terminal/OutcomeUnknown 규칙을 적용한다. signed 1.0.4 package context에서도 Startup→DryRunReady→Oracle/MariaDB Healthy가 기록됐고 playout command·PROGRAM·OutcomeUnknown은 0건이었다. SQL·경로·인증·requestId를 기록하지 않는다. | 운영 회차에서도 로그를 명령 재시도 근거로 사용하지 않고 authoritative 출력과 함께 판독한다. |
-| LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external`과 `reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. |
-| LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. |
-| LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 완료 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js`의 `renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs`; package 증거 `docs/RELEASE_1_0_4_AUDIT.md` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮겼다. signed 1.0.4 package에서 실제 `삼성` 검색 63건을 확인하고 10개 업무 탭을 모두 방문한 뒤에도 검색어·결과·31개 컷이 유지됐다. package는 계속 DryRun이었다. | Web 413/413, source Web 33개 package byte 일치, 설치 package UI PASS. 향후 새 업무 탭도 같은 독립 패널 계약을 유지한다. |
-| LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. |
-| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 코드 완료 / DB view 정합성 대기 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedNxtThemeRestore.cs`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 테스트 `LegacyNamedNxtThemeRestoreServiceTests.cs`, `named-nxt-theme-restore-workflow.test.cjs`, `named-nxt-theme-restore-app-integration.test.cjs`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md`, `docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md` | 제목 exact lookup, 현재 Maria code, 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER와 fresh PageN을 닫힌 batch로 복원한다. 실제 48행/24제목은 current identity가 유일하고 저장 code는 전부 stale다. `SB_ITEM` 446행과 NXT master 446행은 존재하지만 원본과 새 앱이 공통으로 쓰는 `v_all_stock` 조인은 0행이어서 48행 모두 `PreviewEmpty`로 차단된다. 통합 PList 결과에서도 `selectionMapped`로 계산하지 않았고 추측성 직접-table fallback을 적용하지 않았다. | Core Debug/Release와 실제 read-only DB smoke PASS, DB write·Tornado/PGM 명령 0건. DBA가 `v_all_stock`의 NXT 포함 의도를 확인해 승인된 변경·rollback으로 복구하거나 직접-table 경로를 별도 승인한 뒤 fresh page preflight/MSIX/PGM을 검증한다. |
-| LF-020 | P2 | 원본 초기 업무 화면 `해외` | 완료 | 원본 `MainForm.Designer.cs` 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js`; 테스트 `operator-ui-app-integration.test.cjs` | dashboard는 진단 화면으로 유지하되 초기 active identity/제목/요청을 해외로 맞췄다. signed 1.0.4 package를 새로 실행했을 때 해외만 active였고 실제 해외 DB 상태가 표시됐다. | 후속 탭 추가 시에도 초기 identity를 순번이 아닌 `data-market`으로 유지한다. |
-| LF-021 | P2 | 업무 탭 드래그 순서 변경 | 완료 | 원본 `MainForm.cs:3183-3312`; 현재 `Web/market-nav-reorder.js`, `Web/app.js`; 테스트 `market-nav-reorder*.test.cjs` | dashboard는 첫 위치에 고정하고 10개 업무 identity만 drag 교환/Alt+위·아래로 이동한다. active view/DB/playlist는 불변이고 입력·modal·PREPARE 잠금에서 fail closed하며 세션 종료 시 기본 순서로 돌아간다. 실제 브라우저에서 `Alt+Down` 후 active 해외 유지와 reload 초기화를 확인했다. | signed 1.0.4 패키지에 source Web 33개 일치로 반영했다. |
-| LF-022 | P2 | GraphE 위/아래 찾기 | 완료 | 원본 `GraphE.cs:338-379,605-611`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `위로 찾기`/`아래로 찾기` 버튼과 Enter 다음 찾기를 포팅했다. trim·대소문자 무시 Contains, 현재 행 전/후, no-wrap, 모든 pending·잠금·hidden 차단을 유지한다. 원본의 `i > 0` 첫 행 누락은 명백한 off-by-one으로 보고 첫 행도 정상 후보에 포함했다. | GraphE 집중 20/20, 전체 Web 413/413과 signed 1.0.4 Web 일치 통과. |
-| LF-023 | P2 | ThemeA/전문가 편집기 Delete 키 | 완료 | 원본 `ThemeA.cs`/`UC6.cs`; 현재 `Web/operator-catalog-ui.js`; 테스트 `operator-catalog-ui.test.cjs` | ThemeA와 expert draft의 포커스/선택 행 하나를 기존 `removeDraftItem`으로 삭제하고 input/textarea/contenteditable, modal 밖, 잠금/DB write 중에는 상태를 변경하지 않는다. 삭제 후 inputIndex를 재배열하며 native 요청은 보내지 않는다. | 관련 UI/workflow/bridge 32/32, 전체 Web 413/413과 signed 1.0.4 Web 일치 통과. |
-| LF-024 | P2 | GraphE 종목명 헤더 정렬 | 완료 | 원본 `GraphE.cs:258-280`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 세션 로컬 display order로 적용했다. 선택 snapshot/native/playlist는 불변이고 위/아래 찾기는 현재 표시 순서를 따른다. 새 화면/목록에서 오름차순으로 초기화한다. | 네 모드·한글/ASCII·빈/단일·pending·정렬된 find 집중 20/20, 전체 Web 413/413 통과. |
-| LF-025 | P2 | UC7 거래정지 종목명 헤더 정렬 | 완료 | 원본 `Control/UC7.cs:173-188`; 현재 `Web/trading-halt-workflow.js`, `Web/app.js`, `Web/index.html`; 테스트 `trading-halt-name-sort-app-integration.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 표시 order에만 적용했다. 동명은 market+stockCode로 결정적으로 정렬하고 선택 identity를 유지하며 새 검색은 server 순서로 초기화한다. DB/native/playlist 변경은 없다. | 집중 18/18, 전체 Web 413/413, 실제 브라우저에서 접근 라벨/가로 overflow 0, signed 1.0.4 Web 일치 통과. |
+| ID | 우선순위 | 기능 | 상태 | 현재 판정과 증적 | 남은 경계 |
+|---|---|---|---|---|---|
+| LF-001 | P0 | `s6001` 해외지수 영상 | 외부자산 필요 | builder와 trusted asset 검사는 완료. 승인 Cuts에 국가 영상 13개가 없음 | 제공 후 asset preflight와 해당 scene PGM |
+| LF-002 | P0 | cut/asset coverage | 완료 | active alias 45개 `.t2s` 존재. alias missing/unsafe 0, asset unsafe 0, asset missing 14를 분리 보고 | 누락 자산 자체는 LF-001/LF-003 |
+| LF-003 | P0 | `s5006` 큐브 배경 | 외부자산 필요 | `Video\큐브배경.vrv` 1개가 승인 Cuts에 없음. builder 누락이 아님 | 제공 후 s5006 DryRun/PGM |
+| LF-004 | P0 | 실제 개발 DB write | 완료 | GraphE 4 + PList/AList 2 + ThemeA KRX/NXT 2 + EList 1, 총 9개가 create/save→fresh readback→delete→absence PASS, 잔여 0 | 운영 DB write를 뜻하지 않음 |
+| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 완료 | 설치된 최신 MSIX에서 `5001` PREPARE/TAKE IN/자동 갱신 → playlist NEXT `5074` 1/2 → Page NEXT 2/2 → TAKE OUT, Network Monitoring·PGM·BYE 확인 | 14개 외부 영상 자산 scene은 제공 후 별도 PGM |
+| LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 |
+| LF-007 | P0 | PageN/NEXT | 완료 | 5·6·12개 단위, 최대 20페이지, 빈/경계/마지막 부분 페이지 테스트와 개발 PGM에서 `5074` 9종목의 5+4 Page NEXT·다음 행 NEXT 확인 | 없음 |
+| LF-008 | P0 | 원클릭 TAKE IN | 완료 | 보이는 TAKE IN/F8 한 번으로 fresh page load 후 PREPARE 1회→TAKE IN 1회. native PREPARE는 진단용으로 유지하되 hidden | unknown에서 재시도 금지 |
+| LF-009 | P0 | C# 권위/strict bridge | 완료 | Web은 intent만 전송. `LegacyBridgeProtocol` allowlist와 `LegacyOperatorController` snapshot이 DB identity·playlist·scene·송출 상태의 권위 | 새 intent에도 동일 경계 유지 |
+| LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정 | 동적 DB 행은 별도 검증 |
+| LF-011 | P1 | UC4/UC6/UC7 클릭 | 완료 | 테마·전문가·거래정지는 원본 단일 클릭. UC4/UC6 행은 fixed tree handler와 독립, stale generation 거부 | 없음 |
+| LF-012 | P1 | GraphE/FSell/VI | 완료 | GraphE CRUD·찾기·정렬·행 double-click, FSell/VI 확인 시 save+playlist append+close, 취소 경계 이관 | s5025/VI 전용 PGM은 LF-005 |
+| LF-013 | P1 | PList/AList | 완료 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS | 기존 blocked raw row는 추정 복원 금지 |
+| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·순서·Delete key 이관, KRX/NXT/expert 개발 DB 시나리오 PASS | scene PGM은 LF-005 |
+| LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 |
+| LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed |
+| LF-017 | P1 | 시스템 종료 확인 | 완료 | package-context에서 기본 취소 확인창의 중립 문구·종료/취소 두 버튼을 대조하고 종료 1회, BYE 1회, 자동 TAKE OUT/반대 명령 없음 확인 | 없음 |
+| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 |
+| LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 |
+| LF-020 | P2 | FarPoint 대체 | 완료 | Web table로 바꾸되 선택·정렬·이동·single/double-click·저장 이벤트를 strict C# intent로 재현 | 활성 Print/Excel export는 원본에도 없음 |
+| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 |
+| LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 |
+| LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 |
-## 활성 PLAY_LIST 2,213행 read-only 재분류
+## DB 검증 상세
-운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는
-감사로 다시 분류했다. 원본 `PList.data_load`의 page 공백 제거를 제외하면 모든
-필드를 그대로 보존했고, 현재 persistence 결과와 2,213/2,213 일치했다.
+### Read-only scene smoke
-| route | 건수 | 현재 결과 |
+실제 Oracle/MariaDB에서 필수 scene selector **33/33**과 전체 query 58건이 성공했다.
+이 검증은 실제 DB → 명시 DTO → scene mutation 경로를 증명한다. `.t2s`, 영상·배경 자산,
+K3D 명령 또는 PGM 화면 검증을 대신하지 않는다.
+
+### 개발 DB write smoke
+
+production service/executor와 충돌 격리된 임시 식별자를 사용했다.
+
+| 시나리오 | 개수 | 검증 |
|---|---:|---|
-| 기존 닫힌 매퍼 | 1,250 | hash 고정 원본 INI와 현재 exact signature로 selection 1,250개 매핑 |
-| 업종 fixed 닫힌 매퍼 | 24 | selection 24개 매핑 |
-| 비교 fresh identity | 99 | selection 74개 매핑, 시장 identity 유실 25개 차단 |
-| 해외지수 candle fresh identity | 61 | selection 61개 매핑 |
-| NXT theme fresh identity | 48 | validated preview empty 48 차단 |
-| KRX theme fresh identity | 587 | current-code remap 94, identity 없음 271, 저장 행 무효 16, preview empty 193, 지원 불가 action 13 |
-| 해외종목 fresh identity | 78 | exact 입력명에서 unique symbol/nation을 correlated materialize해 78개 매핑 |
-| GraphE fresh materialization | 36 | 별도 double-read row-version·stock identity·cut 계약으로 22개 매핑, 저장값 무효 9·identity 중복 5 차단. FSell/VI PList행은 0 |
-| unique closed mapper 없음 | 30 | 원본 328-action runtime hash와 현재 exact signature 중 단일 후보가 없어 nearest scene 추정 없이 차단 |
-| 합계 | 2,213 | 기본 감사 `selectionMapped=1,581`/차단 632; 별도 GraphE proof를 합치면 `selectionMapped=1,603`/차단 610 |
+| GraphE `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` | 4 | create, fresh readback, delete, absence |
+| PList/AList named playlist | 2 | definition/items save, fresh readback, delete, absence |
+| ThemeA KRX | 1 | create, preview/readback, delete, absence |
+| ThemeA NXT | 1 | create, preview/readback, delete, absence |
+| EList expert | 1 | create, preview/readback, delete, absence |
+| 합계 | **9** | **PASS**, 잔여 0 |
-`unique=2,213`은 전부 송출 가능하다는 뜻이 아니라 모든 행이 단일 route의
-selection 결과 또는 명시적 차단 사유를 가졌다는 뜻이다. `selectionMapped`도
-장면 데이터·자산, package context, PREPARE 또는 PGM 성공이 아니다. 이들은 집계
-밖의 별도 gate다. KRX/NXT theme은
-원본 `s5074/s5077/s5088`처럼 제목 exact lookup을 사용하며, 저장 code를 가장 가까운
-현재 code로 추정하지 않는다. 비교 25행도 현재 이름이 우연히 유일하더라도 저장
-행이 시장을 보존하지 않았으므로 계속 차단한다. 상세 SQL 경계와 결과는
-[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)에 기록했다.
+write는 자동 재시도하지 않는다. `OutcomeUnknown`이면 cleanup mutation이나 다음 시나리오를
+보내지 않는 구현·테스트 경계를 유지한다. 이 결과는 개발 DB 검증이며 운영 DB에 임의 쓰기를
+허용하거나 수행했다는 뜻이 아니다.
-## s6001 asset 검사 보강 기준
+## 기존 PLAY_LIST read-only 재분류
-`s6001` 해외지수 target은 다음 13개 파일을 요구한다.
+실제 Oracle 활성 정의 23개와 연결된 2,213행은 원문의 7필드, 한국어, enabled,
+ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다.
-- `Video\20201008_미국.vrv`
-- `Video\20201008_독일.vrv`
-- `Video\20201008_영국.vrv`
-- `Video\20201008_프랑스.vrv`
-- `Video\20201008_일본.vrv`
-- `Video\20201008_중국.vrv`
-- `Video\20201008_홍콩.vrv`
-- `Video\20201008_대만.vrv`
-- `Video\20201008_싱가포르.vrv`
-- `Video\20201008_태국.vrv`
-- `Video\20201008_필리핀.vrv`
-- `Video\20201008_말레이시아.vrv`
-- `Video\20201008_인도네시아.vrv`
+| route | 건수 | 결과 |
+|---|---:|---|
+| 기존 닫힌 매퍼 | 1,250 | selection 1,250 |
+| 업종 | 24 | selection 24 |
+| 비교 | 99 | selection 74, 시장 identity 유실 25 차단 |
+| 해외지수 candle | 61 | selection 61 |
+| NXT theme | 48 | current preview empty 48 차단 |
+| KRX theme | 587 | current-code 94, 나머지 identity/preview/문법/지원 경계로 차단 |
+| 해외종목 | 78 | selection 78 |
+| GraphE | 36 | fresh materialization 22, 값/identity 14 차단 |
+| unique closed mapper 없음 | 30 | nearest-scene 추정 없이 차단 |
+| 합계 | **2,213** | 통합 selection 1,603 / 명시 차단 610 |
-검사 보강은 다음 조건을 만족해야 한다.
+`selectionMapped`는 scene asset, PREPARE나 PGM 성공 수치가 아니다. 상세는
+[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)를 따른다.
-1. `.t2s` alias 결과와 asset 결과를 별도 필드로 보고한다.
-2. 상대 경로만 허용하고 Cuts root 탈출을 거부한다.
-3. 파일 존재, 0바이트 여부, reparse point를 검사한다.
-4. 누락된 상대 경로와 영향을 받는 builder를 운영자가 알 수 있게 하되 secret이나 외부 절대 경로를 출력하지 않는다.
-5. asset이 없는 현재 장비에서는 전체 migration 완료가 아니라 `외부자산 필요`로 끝나야 한다.
-6. 완전 fixture, 단일 누락, 복수 누락, 0바이트, reparse fixture를 자동화한다.
+## 외부자산 목록
-권장 검증 명령 형태:
+45개 active alias의 `.t2s`는 존재한다. 별도 종속 asset 14개만 제공되지 않았다.
-```powershell
-powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 `
- -CutRoot "<승인된 읽기 전용 Cuts root>"
-```
+- `s5006`: `Video\큐브배경.vrv`
+- `s6001`:
+ - `Video\20201008_미국.vrv`
+ - `Video\20201008_독일.vrv`
+ - `Video\20201008_영국.vrv`
+ - `Video\20201008_프랑스.vrv`
+ - `Video\20201008_일본.vrv`
+ - `Video\20201008_중국.vrv`
+ - `Video\20201008_홍콩.vrv`
+ - `Video\20201008_대만.vrv`
+ - `Video\20201008_싱가포르.vrv`
+ - `Video\20201008_태국.vrv`
+ - `Video\20201008_필리핀.vrv`
+ - `Video\20201008_말레이시아.vrv`
+ - `Video\20201008_인도네시아.vrv`
+- 기본 배경: sibling `배경\기본.vrv`
-검사 보강 전의 `Missing=0`은 `.t2s` alias만 의미하며 asset-ready 증거로 사용하면 안 된다.
+이 파일들의 부재는 code/build 실패가 아니라 **외부자산 미제공**이다. 검사기는 존재,
+0바이트, root escape, reparse와 manifest 중복을 검사하고 누락 경로와 영향 builder만
+redacted 형태로 보고한다.
-## 원본 I/O 조사에서 제외 판정한 항목
+## 원본 I/O에서 의도적으로 제외한 항목
-- 활성 Print/Excel/FarPoint export/import 호출은 발견되지 않았다.
-- `MainForm.cs:1767-1772,1796-1805`의 CSV playlist save/load는 주석 처리돼 있다.
-- `MainForm.GetThumnail`과 preview watcher/`SaveImageFile` 호출도 활성 경로가 아니다.
-- `s8086`은 scene builder는 존재하지만 원본 MainForm active alias가 없어 의도적으로 unreachable이다. 이를 UI 누락으로 계산하지 않는다.
-- FarPoint의 표·선택·정렬·이동 자체는 Web table/workflow로 변환할 수 있지만, 변환 UI가 존재한다는 사실만으로 DB write나 PGM 동등성이 완료되지는 않는다.
+- 활성 Print/Excel/FarPoint export/import 호출은 원본에서 발견되지 않았다.
+- CSV playlist save/load, thumbnail watcher와 `SaveImageFile`은 주석 또는 미호출 경로다.
+- `s8086`은 builder는 있으나 원본 MainForm active alias가 없는 diagnostic route다.
+- 위 dead/hidden 경로를 새 활성 기능으로 만들지 않는다.
-## 기존 완료 문서 교정 후속
+## 최종 증적 기록
-다음 문서는 구현 존재, 자동 계약 테스트, 실데이터 read smoke, 외부 asset readiness, 실제 DB write, 실제 PGM 검증을 서로 다른 열로 분리해야 한다.
+확정된 증적과 최신 전체 회귀의 기록 위치는
+[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)다.
-| 문서 | 교정할 표현 | 후속 교정 내용 |
-|---|---|---|
-| `docs/MIGRATION.md:69-74` | MainForm/UC1~UC7과 전용 화면을 포괄적으로 `완료`로 표현 | `화면·bridge 구현`과 `운영 동등성 검증`을 분리한다. PList 한국어 복원, s6001 asset, 원본 파일 one-time import, DB write, 장면별 PGM 상태를 이 문서와 맞춘다. |
-| `docs/OPERATOR_UI_PARITY.md:48-62` | 다수 화면을 `화면 통합 완료`로 표현 | 해당 표현은 구조적 UI 도달성만 뜻한다고 명시하고 DB-W/PGM 미검증을 상단 요약과 각 행에 유지한다. 완료 판정은 실제 검증 열까지 충족할 때만 사용한다. |
-| `docs/OPERATOR_UI_PARITY.md:75,111-113,214-218` | PList와 비교쌍 load/save를 완료로 표현 | PList 한국어 실DB 복원과 `종목비교.dat` one-time import를 별도 미완료 항목으로 추가한다. |
-| `docs/OPERATOR_UI_PARITY.md:77` | F2/F3 배경을 완료로 표현 | 별도 sibling `배경` root와 `기본.vrv` 코드/자동 검증은 반영됐다. 실제 자산이 없으므로 package/PGM 완료로 확대하지 않는다. |
-| `docs/SCENE_EQUIVALENCE.md:17,98` | 전체 real-data smoke 및 s6001을 asset 경고 없이 통과로 표현 | DB→DTO→mutation smoke와 실제 asset preflight를 분리하고 s6001 국가 영상 13개를 `외부자산 필요`로 기록한다. |
-| `docs/PLAYOUT_OPERATIONS.md:86,94` | s5025 환경 변수 안내와 alias-only cut 검사 | 정상 앱의 private manual store 경로와 one-time import 정책을 정확히 설명한다. cut coverage 결과에 asset 검사 상태를 추가한다. |
-| `docs/PLAYOUT.md:97,348-366` | Web 배경 변경 불가 설명 및 자동 검증을 35개 완료 근거로 사용 | F2/F3 native picker의 실제 경계를 반영한다. 5001/5074 PGM 성공과 나머지 scene 자동 검증을 명확히 분리한다. |
+다음 값은 추측하거나 이전 package 결과를 재사용하지 않는다.
-이 교정은 기존 구현 성과를 취소하는 것이 아니라, `구현됨`과 `실제 방송 동등성 완료`를 같은 의미로 사용하지 않기 위한 것이다.
+| 항목 | 상태 |
+|---|---|
+| Debug/Release x64 전체 테스트 개수 | **PASS** — .NET 각 2,394/2,394, Web 435/435 |
+| 새 Release x64 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, Valid, `CN=Comtrophy` |
+| 새 package-context 실행 | **PASS** — DryRun과 개발 Live, 정상 종료 |
+| 개발 Tornado2 Network Monitoring/PGM 결과 | **PASS** — `5001` → `5074` 1/2 → 2/2 → TAKE OUT/BYE |
+| Gitea 반영 commit | **PASS** — 최종 이관·검증 문서 포함 commit을 `origin/main`에 fast-forward push |
-## 완료 판정에 필요한 최종 증거
-
-1. s5006/s6001 포함 모든 reachable scene의 asset preflight 결과
-2. PList 한국어 `DC_TITLE`/`LIST_TEXT` 실DB read 비교
-3. 기존 `종목비교.dat`, FSell, VI 데이터의 안전한 one-time migration 결과
-4. 승인된 테스트 데이터로 수행한 GraphE/PList/ThemeA/EList 및 manual file write read-back
-5. Debug/Release x64 회귀와 Release MSIX package-context 실행
-6. 승인된 scene·회차의 Network Monitoring 명령, callback, PGM 화면
-7. timeout·OutcomeUnknown에서 재시도하지 않았다는 운영 기록
-
-이 증거가 갖춰지기 전에는 전체 원본 UI 또는 35개 scene의 동등성 목표를 완료 처리하지 않는다.
+원본 저장소는 최종 검증과 배포 이후에도 계속 읽기 전용으로 유지한다.
diff --git a/docs/LEGACY_PARITY_REBASELINE.md b/docs/LEGACY_PARITY_REBASELINE.md
index d07393a..c932ff3 100644
--- a/docs/LEGACY_PARITY_REBASELINE.md
+++ b/docs/LEGACY_PARITY_REBASELINE.md
@@ -1,101 +1,141 @@
# 원본 호환 마이그레이션 기준선
-## 결정
+기준일: 2026-07-15
-기존 저장소와 검증된 `Core / Infrastructure / Playout`은 유지한다. 기존 루트 WinUI 앱은 비교용 프로토타입으로 보존하고, 원본 UI와 이벤트를 먼저 재현하는 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 같은 솔루션에서 병행 개발한다.
+## 현재 결정
-WebView는 HTML 컨트롤 표시와 사용자 입력 전달만 담당한다. 검색 정책, 선택 상태, 플레이리스트 필드, 장면 alias, PageN 및 송출 상태의 권위는 C# Application 계층에 둔다.
+마이그레이션의 실행 대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 이 앱은
+`.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2 / Windows x64 / MSIX`로
+구성된 Windows 데스크톱 프로그램이다. WebView2는 앱에 포함된 로컬 HTML/CSS/JavaScript를
+표시하는 컨트롤이며, 별도 웹 서버에 배포하는 웹사이트가 아니다.
-## 기준 원본
+원본 WinForms UI의 화면 구성과 클릭 이벤트를 먼저 동등하게 옮긴 뒤 개선한다. 기존에
+검증된 `Core / Infrastructure / Playout`은 재사용하며, 원본 업무 규칙과 송출 권위는
+C#에 둔다.
-- 읽기 전용 저장소: `C:\Users\MD\source\repos\MBN_STOCK_N`
-- 조사 시 HEAD: `309286309eaf20b58172247263f575753a860700`
-- 실제 실행 컷 목록: `MBN_STOCK_N\bin\Debug\Res\종목.ini`
-- 실행 컷 목록 SHA-256: `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`
-- 실행 컷 목록은 구분행 2개를 포함한 33개 물리행이며 실제 컷은 31개다.
-- 3번 컷은 고객 화면과 실행본을 따라 `1열판기본_시간외단일가`를 사용한다. 소스 `Res`의 `시간외종가`는 기준으로 사용하지 않는다.
+## 기준 원본과 보존 원칙
-원본 작업 트리에는 기존 수정이 있으므로 커밋 SHA만으로 동등성을 판단하지 않는다. 원본은 계속 읽기 전용으로 취급한다.
+- 읽기 전용 원본: `C:\Users\MD\source\repos\MBN_STOCK_N`
+- 원본 저장소와 `MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts`는 분석·검증에만 사용한다.
+- 원본 파일, 원본 설정, 실제 컷과 자산을 수정하거나 새 저장소에 복사하지 않는다.
+- 벤더 DLL, K3D 라이선스, 인증서, DB 비밀번호, 실제 컷·배경 파일과 로컬 운영 설정은
+ Git/MSIX에 넣지 않는다.
+- 실행 컷 목록은 구분행 2개를 포함한 물리행 33개와 실제 종목 컷 31개다.
+- 원본 scene inventory는 35개 builder이며, MainForm에서 도달 가능한 runtime은 34개
+ builder와 45개 active alias다. `s8086`은 원본과 같이 무alias 진단 전용이다.
-## 신규 프로젝트
+## 실행 구조와 권위 경계
-| 프로젝트 | 책임 |
+| 계층 | 책임 |
|---|---|
-| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC 상태, 이벤트 및 업무 규칙 |
-| `MBN_STOCK_WEBVIEW.LegacyBridge` | 허용된 WebView 입력 intent와 표시 snapshot 계약 |
-| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, WebView2, DB composition root, 별도 개발 MSIX |
-| `LegacyApplication.Tests` | 원본 이벤트 및 결과 DTO 동등성 테스트 |
-| `LegacyBridge.Tests` | strict JSON과 Web 권한 경계 테스트 |
-| `LegacyWeb.Tests` | Visual Studio에서 항상 실행되는 Web thin-client 계약 테스트 |
+| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC/Form 상태, 클릭 이벤트, playlist와 업무 규칙의 C# 권위 |
+| `MBN_STOCK_WEBVIEW.LegacyBridge` | WebView가 보낼 수 있는 intent의 strict JSON allowlist와 표시 snapshot 계약 |
+| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, 로컬 WebView2 UI, DB composition root, 송출 연결과 개발 MSIX |
+| `MBN_STOCK_WEBVIEW.Core` | DTO, scene resolver/builder, PageN과 `LegacyPlayoutWorkflow` |
+| `MBN_STOCK_WEBVIEW.Infrastructure` | Oracle/MariaDB와 신뢰 파일 저장소 구현 |
+| `MBN_STOCK_WEBVIEW.Playout` | `IPlayoutEngine` 뒤의 K3D/Tornado2 어댑터, STA 큐와 안전 게이트 |
-개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`다. 운영 identity `Wickedness.MBNStockWebView`를 대체하지 않는다.
+WebView는 표시와 사용자 입력 수집만 담당한다. 종목·시장 identity, 7필드 playlist 행,
+scene alias, DB mutation, PageN, PREPARED/PROGRAM 상태는 Web에서 임의 생성하거나
+확정할 수 없다. 모든 입력은 `LegacyBridgeProtocol`의 폐쇄형 intent로 파싱되고
+`LegacyOperatorController`가 다시 검증한 뒤 실행된다.
-## 첫 수직 흐름 상태
+## 이관된 원본 UI와 이벤트
-구현됨:
+- 원본 10개 업무 탭과 탭 순서 변경
+- 모든 탭에서 유지되는 종목 검색, 종목 결과, 31개 컷과 playlist 편집
+- 328개 fixed action, 코스피·코스닥 업종 44개 action, 비교 대상 24개와 비교 9개 action
+- UC4 테마, UC6 전문가와 UC7 거래정지의 원본 단일 클릭 동작
+- UC4/UC6 목록 행과 고정 action 트리의 독립 클릭 경계
+- GraphE 네 수동 재무 화면과 행 더블클릭 추가
+- FSell 개인·외국인·기관 5행 입력과 VI 목록·정렬·페이지 입력
+- PList/AList의 목록, 신규 생성, 저장, 불러오기, 삭제와 행 더블클릭
+- ThemeA와 EList의 검색, 생성, 편집, 삭제, 종목 구성과 순서 변경
+- UC5 해외업종·해외종목·해외지수
+- playlist 단일/Ctrl/Shift 선택, enabled, 위/아래 이동, 선택/전체 삭제
+- F2/F3 배경, Fade, NEXT/Page NEXT, TAKE OUT, 종료 확인과 자동 갱신
-- 검색 버튼의 정확한 빈 문자열 no-op과 Enter 빈 검색 허용
-- KOSPI → KOSDAQ → NXT KOSPI → NXT KOSDAQ 순서 및 DB 반환 순서 유지
-- trim, 전체 정렬, 결과 제한 및 wildcard escape를 하지 않는 원본 호환 검색
-- 바인드 파라미터를 통한 SQL injection 방지
-- master snapshot을 이름으로 다시 순회하고 마지막 일치를 채택하는 `jongmokCode` 규칙
-- 33개 물리 컷 행과 31개 display 번호
-- 원본 `ClientSize 1905 x 1015`, `FixedSingle`, 최대화 불가 창 계약
-- 일반/Ctrl/Shift/Ctrl+Shift 선택을 C# 상태로 처리
-- 0.5초 이내, 동일 행, 동일 좌표 조건의 원본 더블클릭 판정
-- 선택 컷 drag/drop과 물리 index 오름차순 추가
-- 원본 7필드 플레이리스트 행과 중복 추가 허용
-- NXT `1열판기본_현재가`만 허용하고 나머지는 원본 경고 표시
-- PREPARE 직전 `LegacyPlaylistEntry`로 변환할 수 있는 31개 컷 행의 alias 매핑
+FarPoint 표 자체는 Web table로 바뀌었지만 원본의 선택, 정렬, 이동, 단일/더블클릭,
+저장·불러오기 이벤트 경계는 C# intent와 snapshot으로 유지한다.
-아직 이관하지 않음:
+## 송출 동작
-- 좌측 수동 입력 GraphE 4개 모달
-- 중앙 UC1~UC7과 모든 탭 이벤트
-- 플레이리스트 선택/삭제/정렬/DB 저장·불러오기
-- 배경, 이동평균, Fade/Update 시간 컨트롤
-- PREPARE/TAKE IN/NEXT/TAKE OUT 실제 연결
-- 운영 DB write와 Tornado2/PGM 검증
+운영자 화면에는 원본처럼 `TAKE IN`, `NEXT`, `TAKE OUT`을 제공한다. 별도 native
+`PREPARE` 버튼은 엔지니어링 진단용으로 DOM에 유지하지만 숨겨져 있다. idle 상태에서
+`TAKE IN` 또는 F8을 한 번 실행하면 C#이 같은 게이트 안에서 다음 순서를 정확히 한 번씩
+수행한다.
-미이관 버튼은 현재 disabled 상태이며, 연결 상태는 `미연결`로 명시한다. 새 앱은 Tornado2에 연결하지 않는다.
+1. 선택 playlist와 page를 C# snapshot으로 고정
+2. 실제 DB/신뢰 파일에서 DTO를 다시 조회
+3. `BeginTransaction` → mutation → `QueryVariables` → `EndTransaction`
+4. `Prepare`
+5. `TakeIn`
+
+PREPARE 성공 뒤 TAKE IN이 실패하거나 결과가 불명확하면 prepared 소유권을 유지하고
+자동 재시도하지 않는다. `NEXT`는 같은 scene의 다음 PageN과 다음 enabled playlist 행을
+원본 조건으로 구분한다. timeout과 `OutcomeUnknown`에서는 반대 명령, cleanup 명령 또는
+동일 명령을 추측해 반복하지 않는다.
+
+기본 송출 모드는 계속 `DryRun`이다. Live는 개발 Tornado2/PGM 설정, 프로세스별 명시적
+환경 승인, scene allowlist, 명령 예산을 모두 통과해야 한다. 이 안전 게이트는 개발 환경에서도
+완화하지 않는다.
+
+## scene, alias와 PageN 기준선
+
+- 원본 scene builder source inventory: **35/35**
+- 도달 가능한 active alias: **45개**, 승인된 읽기 전용 Cuts에 대응 `.t2s` 존재
+- PageN: `s5074=5`, `s5077=6`, `s5088=12`개 단위
+- page count: `min(20, ceil(itemCount / pageSize))`
+- 빈 결과, 첫/마지막 부분 페이지, 정확한 경계, 20페이지 cap, 21페이지 거부,
+ 같은 scene Page NEXT와 다음 playlist NEXT를 자동 테스트로 고정
+
+장면별 세부 상태는 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md), 호출 순서는
+[`LEGACY_PLAYOUT_ANALYSIS.md`](LEGACY_PLAYOUT_ANALYSIS.md)를 따른다.
+
+## DB 검증 기준선
+
+실제 Oracle/MariaDB read-only 스모크는 필수 scene selector **33/33**, 전체 query 58건을
+통과했다. 이는 실제 DB → DTO → mutation 경로 검증이며 외부 영상 자산이나 PGM 출력 성공을
+뜻하지 않는다.
+
+개발 DB write 스모크는 충돌 격리된 임시 식별자로 다음 9개 시나리오의
+create/save → fresh readback → delete → absence를 통과했다.
+
+- GraphE `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` 4개
+- PList/AList named playlist 1개
+- ThemeA KRX 1개와 NXT 1개
+- EList/expert 1개
+
+스모크는 production executor를 사용하고 write 재시도를 하지 않는다. 결과가
+`OutcomeUnknown`이면 cleanup이나 다음 시나리오를 실행하지 않는 규칙도 유지한다.
+DB 비밀번호와 원문 데이터는 문서와 로그에 기록하지 않는다.
+
+## 외부자산 상태
+
+45개 active alias의 `.t2s`는 존재한다. 다만 다음 14개 종속 파일은 현재 승인 Cuts에
+제공되지 않았다.
+
+- `s5006`: `Video\큐브배경.vrv` 1개
+- `s6001`: 국가별 `Video\20201008_<국가>.vrv` 13개
+
+또한 원본 sibling `배경` 디렉터리의 `기본.vrv`도 이 장비에 없다. 이 상태는 scene/UI
+코드 누락이나 마이그레이션 실패가 아니라 **Git/MSIX 밖 외부자산 미제공**이다. 자산이
+없으면 해당 경로는 COM 호출 전에 fail closed하고, 다른 파일로 추정 대체하지 않는다.
## Visual Studio 실행
1. `MBN_STOCK_WEBVIEW.sln`을 연다.
-2. 솔루션 구성을 `ParityDebug | x64`로 선택한다.
-3. `MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 지정한다.
-4. 실행한다.
+2. `x64` 구성을 선택한다.
+3. `src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj`를
+ 시작 프로젝트로 지정한다.
+4. Debug 실행 또는 Release x64 MSIX를 생성한다.
-`PrototypeDebug | x64`는 기존 루트 앱을 배포하는 비교용 구성이다. 두 앱은 서로 다른 MSIX identity와 single-instance key를 사용한다.
+개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`이며 기존 운영 패키지
+identity를 대체하지 않는다.
-기존 `Release | x64`의 자동 배포 대상은 비교용 루트 앱으로 유지한다. 호환 앱 Release는 두 패키지를 동시에 배포하지 않도록 `MBN_STOCK_WEBVIEW.LegacyParityApp` 프로젝트의 MSIX 생성 명령으로 명시적으로 만든다.
+## 2026-07-15 검증 기록
-## 2026-07-15 기준선 검증
-
-- `ParityDebug | x64`와 `Release | x64` 솔루션 빌드: 경고 0, 오류 0
-- Debug/Release 각각 C# 테스트 1,980개 통과
- - Core 1,383 / Infrastructure 173 / Playout 393
- - LegacyApplication 17 / LegacyBridge 7 / LegacyWeb 7
-- Node 기반 JavaScript syntax 및 Web 계약 테스트 7개 통과
-- Release x64 개발 MSIX `0.1.0.0` 생성 및 `CN=Comtrophy` 서명 검증
-- MSIX SHA-256: `D3FF9CF1F63E6BC68AA147C0ED47690FE130CF425C0276B3201F763EC5C50A4B`
-- 패키지 76개 항목 중 인증서, 비밀번호, 라이선스, `.t2s`, 운영 설정 파일 0개
-- 패키지의 `Web/index.html`, `styles.css`, `app.js`가 소스와 byte 단위로 일치
-- 별도 패키지 전체 이름 `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, package family `Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0` 설치·실행 성공
-- 패키지 창의 실제 WebView client bounds `1905 x 1015` 확인
-- 격리 WinForms ListView trace로 최초 Shift의 native 선택 `0..현재행`과 별도 pointer 목록의 `-1..현재행`, 같은 행 Shift의 native 선택 유지와 pointer 목록 clear를 확인하고 두 상태를 분리
-- 실제 DB read-only 경로로 `삼성` 검색 → `삼성출판사` 선택 → `현재가` 동일 좌표 더블클릭 → 원본 7필드 플레이리스트 행 생성 확인
-- 검색 시작 직후 정상 창 닫기 경합 검증에서 10초 이내 종료 및 Windows Application 오류 이벤트 0개
-
-이 검증에서는 DB write, PREPARE, TAKE IN, NEXT, TAKE OUT 및 Tornado2 연결을 실행하지 않았다. 화면의 송출 버튼도 계속 비활성 상태다.
-
-## 다음 이관 순서
-
-1. 플레이리스트 row 선택·Ctrl/Shift·삭제·전체/개별 enabled
-2. 중앙 탭과 UC1부터 원본 이벤트 단위로 수직 이관
-3. 수동 입력 Form과 격리 DB write/read-back
-4. C# playlist authority를 기존 `LegacyPlayoutWorkflow`에 연결한 DryRun PREPARE
-5. 35개 scene/PageN differential trace
-6. 승인된 실제 Tornado2/PGM 검증
-
-각 기능은 원본 입력과 신규 입력의 snapshot, DB 파라미터, 플레이리스트 7필드 및 K3D trace를 비교한 뒤 완료 처리한다.
+확정된 전체 회귀, 새 Release MSIX SHA-256, package-context DryRun/Live, 개발
+Tornado2 Network Monitoring과 PGM 결과는
+[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에
+기록했다. 이전 패키지의 숫자나 해시는 현재 결과로 재사용하지 않았다.
diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md
new file mode 100644
index 0000000..19573e6
--- /dev/null
+++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md
@@ -0,0 +1,124 @@
+# LegacyParityApp 이관 검증 증적 — 2026-07-15
+
+이 문서는 현재 `MBN_STOCK_WEBVIEW.LegacyParityApp`의 구현·통합 최종 검증 결과를
+한곳에 모은다. 원본
+`C:\Users\MD\source\repos\MBN_STOCK_N`은 모든 조사와 테스트에서 읽기 전용으로
+취급했다.
+
+## 제품 형태와 권위 경계
+
+| 항목 | 결과 |
+|---|---|
+| 제품 형태 | `.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2 / x64 / MSIX` Windows 데스크톱 앱 |
+| 웹사이트 여부 | 웹사이트가 아님. WinUI 3 프로세스가 패키지의 로컬 Web 자산을 WebView2로 표시 |
+| 업무 권위 | `LegacyOperatorController`의 C# 상태와 검증된 DTO가 권위 |
+| Web 경계 | `LegacyBridgeProtocol`의 strict JSON intent allowlist; Web은 DB identity, scene alias, playlist 또는 송출 상태를 확정하지 않음 |
+| 벤더 경계 | COM/K3D/Tornado 구현은 `IPlayoutEngine` 뒤에 격리하고 STA 큐와 안전 게이트 유지 |
+| 기본 송출 모드 | `DryRun`; Live는 별도 개발 설정, 프로세스별 승인, allowlist와 명령 예산 필요 |
+| 불명확 결과 | timeout/`OutcomeUnknown`에서 자동 재시도·반대 명령·추측 cleanup 금지 |
+
+## 원본 UI와 클릭 이벤트
+
+| 범위 | 현재 증적 |
+|---|---|
+| 업무 탭 | 원본 10개 탭, identity 기반 활성화와 순서 변경 |
+| 종목 영역 | 모든 탭에서 유지되는 종목 검색, 결과, 31개 컷과 playlist 추가 |
+| 고정/업종/비교 | fixed 328개, 업종 44개, 비교 대상 24개와 action 9개를 폐쇄형 inventory로 고정 |
+| 독립 클릭 | UC4 테마, UC6 전문가, UC7 거래정지의 원본 단일 클릭과 UC4/UC6 행별 독립 처리 |
+| 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결 |
+| 해외 | UC5 해외업종·해외종목·해외지수 조회와 playlist 생성 |
+| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제, drag/batch 경계 |
+| FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장 이벤트 의미 유지 |
+
+상세 이벤트 대조는 [`OPERATOR_UI_PARITY.md`](OPERATOR_UI_PARITY.md)를 따른다.
+
+## 송출 흐름과 장면
+
+| 항목 | 결과 |
+|---|---|
+| 원클릭 TAKE IN | idle에서 보이는 `TAKE IN`/F8 한 번으로 `PrepareAndTakeInAsync`가 page load 1회, PREPARE 1회, TAKE IN 1회를 같은 게이트에서 수행 |
+| native PREPARE | 엔지니어링 진단용 control/intent는 유지하되 운영자 UI에서는 `hidden` |
+| 실패 경계 | PREPARE 후 TAKE IN 실패/unknown 시 prepared state 보존, 자동 재시도 없음 |
+| 장면 기준선 | 원본 builder **35/35** inventory와 mutation/runtime 자동 기준선 통과 |
+| active alias | **45개** alias의 대응 `.t2s`가 승인된 읽기 전용 Cuts에 존재 |
+| PageN | 5·6·12개 단위, 최대 20페이지, 경계·마지막 부분 페이지·21페이지 거부 테스트 |
+| NEXT | 같은 scene의 Page NEXT와 다음 enabled playlist 행 NEXT를 원본 조건으로 구분 |
+
+## 실제 DB 검증
+
+| 검증 | 결과 |
+|---|---|
+| Oracle/MariaDB read-only | **PASS** — 필수 scene selector 33/33, 전체 query 58건 |
+| GraphE write | **PASS** — `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` 각 create, fresh readback, delete, absence |
+| PList/AList write | **PASS** — named playlist 정의·항목 create/save, fresh readback, delete, absence |
+| ThemeA KRX write | **PASS** — create, preview/readback, delete, absence |
+| ThemeA NXT write | **PASS** — create, preview/readback, delete, absence |
+| EList write | **PASS** — expert create, preview/readback, delete, absence |
+
+개발 DB write는 총 9개 충돌 격리 시나리오다. production service/executor를 사용했으며
+인증정보나 DB 원문을 출력하지 않았다. write 결과가 불명확한 경우에는 재시도와 cleanup을
+하지 않는 구현·테스트 경계를 유지한다.
+
+## 외부자산 판정
+
+| 자산 | 상태 | 판정 |
+|---|---|---|
+| 45 active alias `.t2s` | 존재 | alias 준비 완료 |
+| `s5006` `Video\큐브배경.vrv` | 1개 없음 | 외부자산 미제공; 코드 실패 아님 |
+| `s6001` 국가별 영상 | 13개 없음 | 외부자산 미제공; 코드 실패 아님 |
+| sibling `배경\기본.vrv` | 없음 | 외부자산 미제공; F2/F3/default background는 fail closed |
+
+누락 자산은 Git 또는 MSIX에 임의로 넣지 않는다. 제공되면 같은 trusted-root,
+0-byte, reparse, root-escape 검사를 통과한 뒤 해당 scene만 다시 검증한다.
+
+## 개발 Tornado2와 PGM 실측
+
+2026-07-15 개발 Tornado2 `2.7.5.49 x64 DSX10.0 S.R2`의 단일 `30001/tcp`
+listener와 K3D x64 등록·라이선스를 고정한 뒤, 설치된 MSIX package-context 앱에서 다음
+시퀀스를 실행했다.
+
+1. 삼성전자 검색 → `5001` playlist 추가
+2. 원본과 같은 fixed leaf **더블클릭**으로 VI 수동 목록 9종목 조회 → `5074` 2페이지 추가
+3. `PREPARE 5001 page 1` → `TAKE IN` → `OnScenePlayed` → 자동 갱신 정확히 1회
+4. `NEXT`로 다음 playlist 항목 `5074 page 1/2` → 자동 갱신 정확히 1회
+5. 같은 scene `Page NEXT`로 `5074 page 2/2`의 마지막 4종목 송출
+6. `TAKE OUT All` → `IDLE` → 정상 종료 확인 → `BYE`
+
+첫 실측과 화면 증적용 known-outcome 재현은 모두 `OutcomeUnknown=false`로 끝났다. 불명확
+결과 뒤 명령 재시도는 없었다. Network Monitoring 최종 원문은 1,863줄,
+299,616문자, SHA-256
+`7CDB183A2518EF47E5CC3819C2EB14C06492DE0C8E018350A0633E0FBBB5B994`다. 두 실측을
+합쳐 `LOAD_SCENE` 4/4, `BEGIN_TRANSACTION` 12/12, `VALUE_SET` 258/258,
+`VISIBLE` 568/568, `SCENE_PREPARE` 12/12, `PLAY` 10/10, `STOPAL` 2/2,
+`UNLOAD_SCENE` 4/4가 요청/성공으로 일치했고 `SCENE_PLAYED` 10회를 수신했다. 정상
+종료에서는 `BYE` 요청 1회가 기록됐고 이후 established 연결과 앱/CDP process는 0개다.
+
+화면 증적은 300프레임 중 72프레임이 검은 기준 화면과 달랐다. `5001` 삼성전자 화면,
+`5074` page 1의 5종목, page 2의 4종목을 직접 확인했고 TAKE OUT 뒤 기준 검은 화면으로
+복귀했다. 프레임 원본과 Network Monitoring 원문은 실제 컷·운영자료를 Git에 넣지 않는
+정책에 따라 로컬 `artifacts/final-validation-20260715`에만 보관한다.
+
+## 최종 전체 회귀 기록
+
+아래 값은 모든 병렬 변경과 fixed-leaf 더블클릭 회귀 테스트가 합쳐진 뒤 다시 실행한
+최종값이다.
+
+| 항목 | 최종 결과 |
+|---|---|
+| Debug x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `ParityDebug|x64`, 경고 0·오류 0 |
+| Release x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `Release|x64`, 경고 0·오류 0 |
+| Web/LegacyParity 전체 테스트 | **PASS** — 47파일, 435/435 (`Test-WebPlayout` 415 + thin-client 20) |
+| Release x64 MSIX 경로·버전 | `artifacts/final-validation-20260715/package/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix`, `0.1.0.0`, 15,200,545 bytes |
+| 새 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, **Valid**, `CN=Comtrophy` |
+| 설치된 package-context 실행 | **PASS** — `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, DryRun 전체 흐름과 Live 개발 PGM 실행, 정상 종료 |
+| 개발 Tornado2 Network Monitoring | **PASS** — HELLO, transaction/mutation/query/prepare/play, event, unload, STOPAL, BYE와 연결 해제 확인 |
+| 개발 PGM PREPARE → TAKE IN → NEXT/Page NEXT → TAKE OUT | **PASS** — `5001` → `5074` 1/2 → `5074` 2/2 → 검은 화면 복귀 |
+| Gitea main commit/push | **PASS** — 이 최종 검증 문서를 포함한 커밋을 `origin/main`에 fast-forward push |
+
+## 판정 경계
+
+- 35/35 자동 장면 동등성은 모든 장면의 실제 PGM 출력 완료를 뜻하지 않는다.
+- 33/33 DB selector 통과는 외부 영상/배경 자산 준비를 뜻하지 않는다.
+- 개발 DB 9개 write 시나리오 통과는 운영 DB에 쓰기를 허용하거나 수행했다는 뜻이 아니다.
+- 개발 Tornado2/PGM은 안전 게이트를 유지한 별도 통합 검증 대상이다.
+- 원본 저장소, 실제 Cuts, 라이선스와 인증정보는 계속 읽기 전용 또는 Git 외부로 유지한다.
diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md
index 3c75413..9332da4 100644
--- a/docs/OPERATOR_UI_PARITY.md
+++ b/docs/OPERATOR_UI_PARITY.md
@@ -1,288 +1,175 @@
# 운영자 UI 동등성 인벤토리
-## 문서 목적과 판정 기준
+기준일: 2026-07-15
-이 문서는 원본 WinForms 운영 화면의 사용자 동작을 새 WinUI 3/WebView 구현에 1:1로 연결한다. 장면 builder가 존재하는 것, 네이티브 bridge와 workflow 모듈이 존재하는 것, 실제 Web 화면에서 운영자가 사용할 수 있는 것은 서로 다른 완료 조건으로 취급한다.
+## 제품과 판정 기준
-상태 표기는 다음과 같다. 아래 기존 행의 `화면 통합 완료` 표현도 `화면/계약 구현`과 같은 구조적 도달성만 뜻하며, 운영 동등성 완료로 읽지 않는다.
+현재 이관 앱은 웹사이트가 아니라 `.NET 8 / C# / WinUI 3 / WebView2` Windows 데스크톱
+프로그램인 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. WebView2는 패키지 안의 로컬
+HTML/CSS/JavaScript를 표시한다.
-| 상태 | 의미 |
+Web은 원본 UI를 표현하고 사용자 intent를 전달한다. 원본 이벤트의 상태 전이, DB identity,
+playlist, PageN과 송출 상태는 `LegacyOperatorController`의 C# snapshot이 권위다. Web 메시지는
+`LegacyBridgeProtocol`의 strict JSON allowlist에 없는 필드·값·명령을 거부한다.
+
+이 문서의 판정은 다음을 구분한다.
+
+| 판정 | 의미 |
|---|---|
-| 화면/계약 구현 | 현재 `Web/index.html`과 `Web/app.js`에서 검색·선택·플레이리스트 추가 또는 저장 동작에 도달하며 native bridge·workflow 계약이 있다. |
-| 화면/계약 구현 / 실환경 검증 대기 | 화면, native bridge, workflow와 자동 테스트까지 연결됐지만 운영 DB write, 기존 데이터 identity/문자 복원 또는 해당 경로의 실제 PGM 확인을 수행하지 않았다. |
-| 운영 동등성 일부 검증 | 승인된 제한 데이터·scene·회차만 실제 DB/PGM으로 확인했다. 확인하지 않은 범위로 확대하지 않는다. |
-| 운영 동등성 완료 | 원본과 같은 입력·저장·재조회·identity·PGM 결과를 해당 기능 범위 전체에서 확인한 상태다. 현재 전체 UI에 이 판정을 적용하지 않는다. |
+| UI/이벤트 이관 | 원본 컨트롤과 클릭 이벤트가 실제 LegacyParityApp 화면 → strict bridge → C# workflow로 연결됨 |
+| 자동 동등성 | 원본 inventory·DTO·mutation·호출 순서·경계값을 자동 테스트로 비교함 |
+| 개발 DB 통합 | 실제 개발 Oracle/MariaDB에서 create/readback/delete/absence를 확인함 |
+| 개발 PGM 통합 | 개발 Tornado2 Network Monitoring과 PGM 화면을 함께 확인함 |
+| 외부자산 필요 | 코드 경로는 있으나 Git/MSIX 밖의 실제 영상·배경이 제공되지 않음 |
-`DB-W`는 운영 데이터베이스에 실제 쓰기를 수행해 확인했는지를 뜻한다. `PGM`은 실제 Tornado2 PGM 출력으로 해당 UI 경로를 확인했는지를 뜻한다. fake executor, DryRun, SQL 계약 테스트는 자동 테스트 근거이지 실제 DB-W 또는 PGM 검증으로 계산하지 않는다.
+## 원본 보존
-기능별 미적용·적용 중·외부자산 필요 판정과 완료에 필요한 증거는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
+- 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했다.
+- 원본 `Cuts`도 읽기 전용 검증에만 사용하며 수정·복사하지 않는다.
+- 비밀번호, 라이선스, 벤더 DLL, 인증서, 실제 컷·영상·배경과 로컬 설정은 Git에 넣지 않는다.
-## 원본 보존 상태
+## 원본 실행 action inventory
-- 원본 저장소는 읽기 전용으로만 분석했고 파일을 수정하지 않았다.
-- 분석 시작 시 원본에 이미 존재하던 dirty 파일은 `MBN_STOCK_N/MainForm.cs`, `MBN_STOCK_N/RES/MmoneyCoder.ini` 두 개였다. 두 파일의 기존 변경을 그대로 보존했다.
-- 이 문서에는 비밀번호, 실제 방송 데이터, 자산·경로 해시를 기록하지 않는다.
+동적 테마·전문가 DB 행과 전용 편집기의 데이터 행은 아래 고정 수량에 중복해 더하지 않는다.
-## 412개 실행 action의 고정 분해
+| 집합 | 원본 UI | 수량 | 현재 경계 | 상태 |
+|---|---|---:|---|---|
+| fixed action | MainForm 해외·환율·지수와 UC1 | **328** | `LegacyFixedActionCatalog` + strict action/section intent | 이관 |
+| 업종 action | UC1 + UC2 | **44** | `LegacyIndustryActionCatalog` + C# section batch | 이관 |
+| 종목 컷 | MainForm 좌측 검색·컷 | **31** | `LegacyStockCutCatalog` | 이관 |
+| 비교 action | UC1 + UC3 | **9** | 24개 target + `LegacyComparisonWorkflowController` | 이관 |
+| 업무 탭 | MainForm TabControl | **10** | identity 기반 활성화·순서 변경 | 이관 |
-412는 다음 네 집합의 합이다. 동적 테마·전문가 목록과 GraphE 편집 행 수는 이 숫자에 중복해 더하지 않는다.
+328개 fixed action 중 수동 전제 6개는 임의 DTO로 만들지 않는다. 개인·외국인·기관
+순매도 3개는 FSell의 신뢰된 5행 입력으로, 5단·6종목·12종목 트리의 VI placeholder 3개는
+원본 VIList가 만든 `s5074/5074` 5개 단위 PageN으로만 활성화한다.
-| 집합 | 원본 UI | 수량 | 새 구현 | 자동 근거 | 상태 |
-|---|---|---:|---|---|---|
-| 고정 action | `MainForm`의 해외·환율·지수 탭과 `Control/UC1.cs` | 328 | `Web/legacy-fixed-workflow.js` | `tests/Web/legacy-fixed-workflow.test.cjs`가 328개, 직접 생성 가능한 322개, 수동 전제 6개를 고정한다. | 화면 통합 완료. 수동 6개는 아래 대체 경계를 통과해야 한다. |
-| 업종 action | `Control/UC1.cs` + `Control/UC2.cs`의 코스피·코스닥 | 44 | `Web/industry-workflow.js`의 시장별 22개 | `tests/Web/industry-workflow.test.cjs` | 화면 통합 완료 |
-| 종목 action | `MainForm` 좌측 종목 검색·컷 목록 | 31 | `Web/operator-workflow.js` | `tests/Web/operator-workflow.test.cjs`가 실행 자산 순서의 31개를 고정한다. | 화면 통합 완료 |
-| 비교 action | `Control/UC1.cs` + `Control/UC3.cs` | 9 | `Web/comparison-workflow.js` | `tests/Web/comparison-workflow.test.cjs`가 24개 비교 대상과 9개 action을 별도로 고정한다. | 화면 통합 완료 |
-| 합계 | | **412** | | | |
+## 화면 단위 현황
-고정 action 328개 안의 수동 전제 6개는 일반 고정 DTO로 임의 생성하지 않는다.
-
-| 원본 고정 항목 | 수량 | 원본 전용 폼 | 새 대체 경계 | 관계 |
-|---|---:|---|---|---|
-| 개인·외국인·기관 순매도 상위(수동) | 3 | `Form/FSell.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 s5025 파일 저장소 | 각 audience의 검증된 5행 입력이 있어야 `s5025/5025` 플레이리스트 항목을 만든다. |
-| 5단·6종목·12종목 섹션의 VI 발동(수동) placeholder | 3 | `Form/VIList.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 VI 저장소 | 세 placeholder 모두 원본 VIList가 만든 하나의 `s5074/5074` 5행 페이지 결과로 대체된다. 6·12행 장면으로 바꾸지 않는다. |
-
-현재 이 수동 6개는 FSell/VIList 대체 화면까지 통합됐다. 고정 카탈로그에서 임의 DTO로 직접 활성화하지 않고, 전용 입력 화면이 저장 후 재조회한 신뢰 데이터와 버전을 공급한 뒤에만 플레이리스트에 넣는다.
-
-## 화면 단위 완료 요약
-
-| 원본 화면 | 핵심 역할 | 화면/계약 구현 상태 | 운영 동등성 DB-W | 운영 동등성 PGM |
+| 원본 화면 | 이관된 핵심 동작 | 현재 구현 | 개발 DB/파일 검증 | 개발 PGM/자산 경계 |
|---|---|---|---|---|
-| MainForm | 종목 검색, 31개 컷, playlist, 송출·배경·저장 | 화면/계약 구현. sibling `배경` root 코드는 적용됐고 실제 외부 배경 자산·package 검증 대기 | 없음/해당 없음 | 일부: 승인된 `5001`, `5074`, TAKE OUT |
-| UC1 | 328 fixed, 업종·비교 action 트리 | 412 action과 수동 6개 전용 편집 화면/계약 구현 | 해당 없음 | 일부: generic `5074`; 전체 action 미검증 |
-| UC2 | 업종 목록과 두 업종 선택 | 화면/계약 구현 | 해당 없음 | 미검증 |
-| UC3 | 비교 대상·저장 비교쌍·9 action | 화면/계약과 strict `종목비교.dat` one-time importer 구현 | importer는 read-only이며 DB-W 없음 | signed package에서 8쌍 1회 실행·재시작 영속성 통과; PGM 미검증 |
-| UC4 | 테마 검색·preview·5/6/12행 항목 | 조회/선택 및 ThemeA CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 KRX/NXT identity 미검증 | 미검증 |
-| UC5 | 해외업종·해외종목·해외지수 | 화면/계약 구현 | 해당 없음 | 미검증; `s6001` 국가 영상 13개 외부자산 필요 |
-| UC6 | 전문가 조회·추천 preview·5행 항목 | 조회/선택 및 EList/추천 CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 identity 미검증 | 미검증 |
-| UC7 | 거래정지 검색·현재/120일 캔들 | 화면/계약 구현 | 해당 없음 | 미검증 |
-| GraphE | 4개 수동 재무 CRUD와 playlist 추가 | 화면/계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
-| FSell | 개인·외국인·기관 수동 순매도 5행 | 화면/계약, 원본 importer와 production parser 구현 | 운영 데이터 write 미실행 | 현재 3 audience×5행 read-only 검증; 기존 파일 불변 |
-| VIList | 수동 VI 목록·순서·5개 단위 page | 화면/계약, 원본 importer와 versioned production parser 구현 | 운영 데이터 write 미실행 | 현재 9항목·2 page read-only 검증; 기존 파일 불변 |
-| PList | 이름 있는 playlist 목록·저장·불러오기·삭제 | 화면/계약과 한국어 legacy/KRX/NXT/해외종목/manual restore 구현 | 운영 DB write 미실행 | 2,213행 무손상, 통합 selection 1,603/차단 610; scene/PGM 별도 |
-| AList | 프로그램 코드·이름 생성 | PList 화면/계약에 통합 | 운영 DB write 미실행 | 해당 없음 |
-| ThemeA | KRX/NXT 테마 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
-| EList | 전문가 생성과 추천 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
+| MainForm | 종목 검색, 31컷, playlist, 배경, 송출, 단축키, 종료 확인 | LegacyParityApp shell + C# controller | 실제 DB 검색/read smoke PASS | 송출 최종 결과는 검증 문서에 기록 |
+| UC1 | 328 fixed, 업종·비교 트리, 부모/leaf batch | C# 폐쇄 inventory와 원자적 section materialize | read-only selector 검증 | 장면별 PGM은 별도 |
+| UC2 | 업종 검색, 두 슬롯 회전·교환, 44 action | industry workflow/controller | Oracle read PASS | 장면별 PGM은 별도 |
+| UC3 | 비교 대상·검색·쌍 CRUD·9 action | comparison workflow/controller | 원본 8쌍 1회 import·재시작 영속성 검증 | 장면별 PGM은 별도 |
+| UC4 | KRX/NXT 테마 검색·preview·5/6/12 선택 | theme workflow; 목록 행 단일 클릭 | KRX/NXT selector PASS | 외부 scene 자산과 PGM 별도 |
+| UC5 | 해외업종·해외종목·해외지수 | overseas workflow/controller | Oracle selector PASS | `s6001` 영상 13개 외부자산 미제공 |
+| UC6 | 전문가 검색·추천 preview·5행 선택 | expert workflow; 목록 행 단일 클릭 | selector PASS | 장면별 PGM은 별도 |
+| UC7 | 거래정지 검색·현재/120일 캔들 | trading-halt workflow; 원본 단일 클릭 | Oracle selector PASS | 장면별 PGM은 별도 |
+| GraphE | 4개 재무 CRUD, 정렬·찾기, 행 더블클릭 playlist 추가 | `LegacyManualFinancialWorkflow` | 개발 DB 4개 테이블 write PASS | 전용 scene PGM 별도 |
+| FSell | 개인·외국인·기관 각 5행, 확인 시 저장+추가+닫기 | `LegacyManualListsWorkflow` + trusted store | 원본 형식 read와 격리 write 검증 | `s5025` trusted 파일 전제 |
+| VIList | 종목 추가·삭제·이동, 5개 단위 page, 확인 시 저장+추가+닫기 | `LegacyManualListsWorkflow` + trusted store | 원본 형식 read와 격리 write 검증 | `s5074` 전용 입력 PGM 별도 |
+| PList | 목록, 행 더블클릭 load/save, 확인, 삭제 | `LegacyNamedPlaylistWorkflow` | 개발 DB create/save/read/delete/absence PASS | 복원 항목별 scene PGM 별도 |
+| AList | 다음 코드와 프로그램명 신규 생성 | named playlist workflow에 통합 | 위 named playlist 시나리오로 PASS | 해당 없음 |
+| ThemeA | KRX/NXT 테마 생성·편집·삭제, 종목 구성·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB KRX/NXT 각 1개 PASS | scene PGM 별도 |
+| EList | 전문가 생성·편집·삭제, 추천 종목·금액·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB expert 1개 PASS | scene PGM 별도 |
-## MainForm 매핑
+UC4/UC6의 결과 행은 고정 action 트리의 click handler에 버블링하지 않는다. UC4 테마,
+UC6 전문가와 UC7 거래정지는 원본처럼 **단일 클릭**으로 선택/playlist 동작을 수행하고,
+fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처리한다.
-원본 파일은 `MainForm.cs`와 `MainForm.Designer.cs`다.
+## MainForm 이벤트 매핑
-| 원본 이벤트·컨트롤 | 원본 동작과 입력/저장 경계 | 새 구현 파일·bridge/workflow | 현재 상태 | 자동 테스트 근거 | DB-W / PGM |
-|---|---|---|---|---|---|
-| `Form1_Load`, `tabControl1_SelectedIndexChanged` | 실행 자산을 읽고 UC1~UC7을 탭에 배치하며 Tornado에 연결한다. | `MainWindow.xaml.cs`, `MainWindow.Playout.cs`, `Web/index.html`, `Web/app.js` | 화면 통합 완료 | `LegacyWebWorkflowIntegrationTests.cs`, `PlayoutBridgeProtocolTests.cs`, Web 전체 회귀 | DB-W 해당 없음 / PGM 일부 완료 |
-| `tab_MouseDown`/`tab_MouseMove`/`tab_DragOver`, `swapTabPages` | 원본 10개 업무 탭의 표시 순서를 drag 중 identity 단위로 교환한다. | `Web/market-nav-reorder.js`; dashboard 고정; `data-market` 기반 drag와 Alt+위/아래; 세션 한정 | 코드 적용 / signed package 검증 대기. active view·DB 요청·playlist 상태를 변경하지 않음 | 전용 10/10, 전체 Web 353/353, Debug/Release x64 build | DB-W·PGM 해당 없음 |
-| `button9_Click`, `txtSearch_KeyPress`, `searchStock`, `jongmokCode` | 코스피·코스닥·NXT 두 시장씩 검색하고 이름, 코드, 시장을 선택한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs`; native `search-stocks`; `Web/operator-workflow.js` | 화면 통합 완료 | `LegacyStockSearchServiceTests.cs`, `operator-workflow.test.cjs` | DB-W 없음 / `5001` 경로 일부 PGM 완료 |
-| `listView1_*`, `GridView1_*`, `SetPlayList`, `PlayList_DragDrop` | 실행 자산의 종목 컷을 선택·드래그해 enabled, 시장, 종목, 종류, 세부사항, page, 종목코드 행을 만든다. | `Web/operator-workflow.js`, `Web/app.js`의 종목 workflow와 표준 playlist entry | 화면 통합 완료 | `operator-workflow.test.cjs`, `candle-options-workflow.test.cjs` | DB-W 없음 / 전체 31개 PGM 미검증 |
-| `b1_Click` (`주요매출 구성`, `성장성 지표`, `매출액`, `영업이익`) | `GraphE` 네 모드를 연다. | `MainWindow.ManualFinancial.cs`, `Web/manual-financial-workflow.js`, `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | GraphE 표 참조 | DB-W 없음 / PGM 없음 |
-| `PlayList_CellClick`, `PlayList_KeyPress`, `checkBox1_CheckedChanged`, `btnup_Click`, `btndown_Click`, `btndelete_Click`, `btntdelete_Click` | 단일·Ctrl·Shift 선택, Space/전체 송출 체크, 순서 이동, 선택·전체 삭제. 송출 중 이동·전체 삭제를 막는다. | `Web/app.js`의 playlist 상태, snapshot lock, `Web/playout-safety.js` | 화면 통합 완료 | `playout-safety.test.cjs`, named/각 workflow의 trusted restore 테스트 | DB-W 없음 / PGM 제어 일부 완료 |
-| `button5_Click`, `btnload_Click` | `PList`를 열어 이름 있는 DB 플레이리스트를 저장·불러온다. | `MainWindow.NamedPlaylists.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `Web/named-playlist-workflow.js` | 화면/계약 구현. 기존 한국어 행과 manual 항목의 실DB fresh restore는 적용 중 | named playlist Core/Infrastructure/Web/bridge 테스트 | DB-W 없음 / 복원 항목별 PGM 없음 |
-| `btnPrepare_Click`, `btnTake_Click`/F8, `btnNext_Click`, `btnTakeout_Click`/Esc | PREPARE, TAKE IN, 같은 장면 page NEXT 또는 다음 행 NEXT, StopAll 기반 TAKE OUT. | `MainWindow.Playout.cs`; `IPlayoutEngine`; native `playout-command`, `request-playlist-page-plans`; `Web/playout-safety.js` | 화면 통합 완료 | playout/Core/PageN/PGM sequence 테스트와 `playout-safety.test.cjs` | DB-W 해당 없음 / 승인된 `5001`, `5074` page 1·2 및 TAKE OUT만 PGM 확인 |
-| `btnback_Click`/F2, `ckback_CheckedChanged`/F3 | 허용 배경 파일 선택, 배경 없음 토글, 송출 중 변경 금지. | `MainWindow.Background.cs`; 별도 `LegacyBackgroundDirectory`; opened-handle/hardlink/reparse 검증; native `request-background-status`, `choose-background`, `toggle-background`; Web 배경 컨트롤 | sibling `배경` root와 `기본.vrv` 코드 적용. 현재 원본 외부 폴더가 없어 fail closed이며 package/PGM 검증 대기 | 별도 root 내부/escape/reparse/hardlink/opened-handle/누락 fixture, Core·Playout Debug/Release 전부 통과, 앱 Debug/Release build | DB-W 해당 없음 / 기능별 PGM 미검증 |
-| `timer1_Tick`, `timer2_Tick`, `timer3_Tick`, `MainForm_FormClosing`, `MainForm_Resize`, keyboard handlers | 자동 갱신, 상태 표시, 종료 정리, 단축키를 담당한다. | playout refresh scheduler, cancellation/lifetime hooks, Web 상태·단축키 | 화면 통합 완료 | refresh epoch/scheduler, playout safety, bridge 테스트 | DB-W 해당 없음 / 일부 PGM 완료 |
-
-실제 PGM 완료는 이미 승인된 제한 회차의 `5001`과 `5074` 흐름만 뜻한다. 412개 전체나 아래 편집 화면의 실제 PGM 동등성을 뜻하지 않는다.
-
-## UC1~UC7 매핑
-
-### UC1 — 해외·환율·지수·업종·비교 action 트리
-
-원본: `Control/UC1.cs`, `Control/UC1.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현 | 상태·검증 |
-|---|---|---|---|
-| `UC1_Load`, `INI_parser` | 탭별 실행 자산을 읽어 category/leaf TreeView를 구성한다. | `legacy-fixed-workflow.js`, `industry-workflow.js`, `comparison-workflow.js`의 폐쇄형 inventory | 화면 통합 완료. 각 Web 테스트가 수량·중복·매핑을 고정한다. |
-| `chE_CheckedChanged`, `treeView_BeforeSelect`, `treeView_BeforeExpand/BeforeCollapse`, `treeView_DrawNode`, `treeView_MouseDown` | 전체 expand/collapse와 트리 표시를 제어한다. | Web category/section 렌더링과 펼침 상태 | 화면 통합 완료. 시각 상호작용이며 DB-W/PGM 대상 아님. |
-| `treeView_DoubleClick` | category 전체 또는 leaf를 playlist에 넣고 5·6·12개 PageN, NXT 제한, 업종 쌍, 비교 쌍을 계산한다. FSell·VI 수동 항목이면 전용 폼으로 분기한다. | 고정 328, 업종 44, 비교 9 workflow의 `create*PlaylistEntry`; page plan bridge; 수동 6개는 `manual-lists-workflow.js`와 `manual-lists-ui.js`로 분리 | 전체 화면 통합 완료. 실제 DB-W 없음, 전체 PGM 미검증. |
-| `treeView_AfterSelect`, `btnswap_Click` | 원본에서 실질 동작이 없거나 주석 처리된 이벤트다. | 별도 기능 없음 | 의도적으로 추가 동작을 만들지 않는다. |
-
-### UC2 — 코스피·코스닥 업종 선택과 쌍
-
-원본: `Control/UC2.cs`, `Control/UC2.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `UC2_Load` | 코스피/코스닥 업종 이름·코드를 DB에서 읽는다. | `src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs`; native `request-industries` | 화면 통합 완료. `LegacyIndustrySelectionServiceTests.cs`, `industry-workflow.test.cjs`. |
-| `listbox_DoubleClick`, `btnswap_Click` | 선택 업종을 두 슬롯에 회전 입력하고 순서를 바꾼다. | `Web/industry-workflow.js`의 selected/primary/secondary 입력 | 화면 통합 완료. 시장 혼합과 빈 슬롯을 fail closed 한다. |
-| UC1 업종 leaf 실행 | 단일, 2열, 수익률 5종, 캔들 12종, 시장 전체 3종으로 시장별 22개를 만든다. | `industry-workflow.js` | 44개 화면 통합 완료. 실제 DB-W 없음 / 업종별 PGM 없음. |
-
-### UC3 — 비교 대상·저장 비교쌍
-
-원본: `Control/UC3.cs`, `Control/UC3.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `UC3_Load`, `Data_Load` | 24개 고정 비교 대상을 읽고 로컬 `종목비교.dat`의 저장쌍을 불러온다. | `Web/comparison-workflow.js`; 24개 typed target; schema-versioned pair list; `LegacyComparisonPairImport`의 고정 read-only source와 strict CP949 9필드 importer | signed package에서 실제 DB identity를 다시 읽어 8쌍을 한 번 가져왔고, 정상 재시작 뒤 private marker·순서를 확인했다. 원본을 계속 참조하지 않는다. |
-| `btnsearch_Click`, `listBox_DoubleClick`, `listBox1_DoubleClick`, `btnsearch2_Click`, `FpSpread2_CellDoubleClick` | 국내 종목, 24개 시장 대상, 해외 종목을 두 슬롯에 회전 입력한다. | native `search-stocks`, `search-world-stocks`; comparison target normalizer | 화면 통합 완료. `comparison-workflow.test.cjs`가 회전·시장·해외 identity를 검증한다. |
-| `btnswap_Click`, `btnAdd_Click`, `btn1/2/3/4_Click`, `File_Save` | 쌍 교환, 추가, 선택/전체 삭제, 위/아래 이동 후 저장한다. | comparison pair append/delete/delete-all/reorder와 Web storage; explicit legacy import button | 화면/계약 구현. 기존 CP949 파일은 exact 9필드·fresh DB identity·중복·500쌍 경계를 모두 통과해야 전체가 원자적으로 저장된다. 실제 8쌍 import와 재시작 marker를 확인했다. |
-| UC1 비교 leaf 실행 | 현재/예상 2열, 비교 캔들/라인, 5일·1/3/6/12개월 수익률의 9개 action | `comparison-workflow.js` | 9개 화면 통합 완료. 실제 DB-W 없음 / 비교 PGM 없음. |
-
-### UC4 — 테마 검색·미리보기·송출 선택
-
-원본: `Control/UC4.cs`, `Control/UC4.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `btn1_Click`, `GetThemeByKeyword` | KRX Oracle과 NXT 저장소에서 테마명·코드를 검색한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs`; native `search-themes`; `Web/theme-workflow.js` | 검색 화면 통합 완료. `LegacyThemeSelectionServiceTests.cs`, `theme-workflow.test.cjs`. |
-| `FpSpread1_CellClick` | 선택 테마의 구성 종목과 입력 순서를 DB에서 읽고 중지 종목을 제외한다. | native `request-theme-preview`와 typed preview | 화면 통합 완료. 중복 key·시장 불일치·truncated preview를 거부한다. |
-| `btnnow_Click`, `btnpre_Click`, `btnnow_6_Click`, `btnnow_12_Click`, double-click | 5단 현재/예상, 6종목 현재, 12종목 현재를 입력순·상승률·하락률로 만들고 5/6/12 page 수를 계산한다. NXT 예상은 차단한다. | `theme-workflow.js`의 compatibility, page preview, playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 테마 PGM 없음. |
-| `btn2_Click`, `btn3_Click`, `btn4_Click` | ThemeA 추가·편집 폼을 열거나 선택 테마와 구성 종목을 삭제한다. | `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | ThemeA CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. |
-
-### UC5 — 해외업종·해외종목·해외지수
-
-원본: `Control/UC5.cs`, `Control/UC5.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `UC5_Load` | 고정 해외지수 목록을 표시한다. | `Web/overseas-workflow.js`의 고정 지수 asset | 화면 통합 완료. |
-| `btnsearch1_Click`, `upjong_search` | 미국 해외업종의 입력명·표시명·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs`; native `search-overseas-industries` | 화면 통합 완료. `LegacyOverseasIndustryIndexSearchServiceTests.cs`. |
-| `btnsearch2_Click`, `jongmok_search` | US/TW 해외종목 이름·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs`; native `search-world-stocks` | 화면 통합 완료. `LegacyWorldStockSearchServiceTests.cs`. |
-| `btn1_Click`, `Account_Graph` | 업종/종목 현재 1열과 지수·종목 5/20/60/120/240일 캔들을 playlist에 넣는다. | `Web/overseas-workflow.js` | 화면 통합 완료. `overseas-workflow.test.cjs`; 실제 DB-W 없음 / 해외 PGM 없음. |
-
-### UC6 — 전문가 목록·추천 종목·송출 선택
-
-원본: `Control/UC6.cs`, `Control/UC6.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `UC6_Load`, `Data_Load`, `FpSpread1_CellClick` | `EXPERT_LIST`를 읽고 선택 전문가의 `RECOMMEND_LIST`를 PLAYINDEX 순서로 미리 본다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs`; native `search-experts`, `request-expert-preview`; `Web/expert-workflow.js` | 검색·선택 화면 통합 완료. `LegacyExpertSelectionServiceTests.cs`, `expert-workflow.test.cjs`. |
-| `btlistadd_Click` | 선택 전문가를 5행 page, 최대 20 page의 전문가 추천 항목으로 만든다. | `expert-workflow.js`의 native-normalized preview와 playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 전문가 PGM 없음. |
-| `btnAdd_Click` | EList 신규 전문가 폼을 연다. | operator catalog expert create와 `operator-catalog-ui.js` | EList 신규 UI 통합 완료. |
-| `btnDelete_Click`, `btndel_Click`, `btnsave_Click`, 종목 검색·double-click | 전문가 삭제, 추천 종목 편집·삭제·재정렬·저장. 원본은 전문가/추천 행을 삭제 후 재삽입한다. | `MainWindow.OperatorCatalogs.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | EList/전문가 CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. |
-
-### UC7 — 거래정지 종목
-
-원본: `Control/UC7.cs`, `Control/UC7.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `btnsearch_Click`, `txts_KeyPress`, `search` | 거래정지 코스피와 코스닥 종목을 빈 검색 또는 부분 검색으로 합친다. | `src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs`; native `search-trading-halts`; `Web/trading-halt-workflow.js` | 화면 통합 완료. `LegacyTradingHaltSelectionServiceTests.cs`. |
-| `FpSpread1_CellClick` | 종목명 열 정렬 | Web 정규화 결과의 결정적 정렬 | 화면 통합 완료. |
-| `btn4_Click` (`현재가`, `120일 캔들`) | 시장·종목 identity로 현재 1열 또는 거래정지 120일 캔들 항목을 만든다. | `trading-halt-workflow.js` | 화면 통합 완료. `trading-halt-workflow.test.cjs`; 실제 DB-W 없음 / PGM 없음. |
-
-## 전용 Form 매핑
-
-### GraphE — 네 가지 수동 재무 편집기
-
-원본: `Form/GraphE.cs`, `Form/GraphE.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `GraphE_Load`, `Data_Init`, `FpSpread1_CellClick`, `Data_Load` | 네 테이블 전체/검색 목록을 읽고 선택 행을 편집 DTO로 분해한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs`; native `request-manual-financial-list`, `request-manual-financial-load`; `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 |
-| 종목 검색, `listBox_DoubleClick`, `upSearch_Click`, `downSearch_Click` | 정확한 종목을 선택하고 테이블 내 이름을 위/아래로 찾는다. | native stock master 재검증과 manual financial list/search | 화면 통합 완료. 이름만으로 시장·코드를 추정하지 않는다. |
-| `btadd_Click` | 기존 이름이면 update, 아니면 insert. `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT`의 폐쇄형 필드를 저장한다. | native `create-manual-financial-record`, `update-manual-financial-record`; `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs` | 화면 통합 완료. 단일 트랜잭션, optimistic `rowVersion`, no retry, OutcomeUnknown quarantine 구현. 실제 DB-W 없음. |
-| `btndel_Click`, `btnall_Click` | 선택 이름 삭제 또는 테이블 전체 삭제 확인 | native `delete-manual-financial-record`, `delete-all-manual-financial-records` | 화면 통합 완료. 실제 DB-W 없음. |
-| `btnAdd_Click`, row double-click, `Select_Item` | DB에 존재하는 행을 정확한 시장/종목과 결합해 1 page playlist 항목으로 추가한다. | native `request-manual-financial-selection`; `Web/manual-financial-workflow.js` | 화면 통합 완료. 저장 복원본은 fresh load + rowVersion/record 일치 + stock 재검증 + native selection 전까지 PREPARE 불가. PGM 없음. |
-
-화면별 폐쇄형 매핑은 다음과 같다.
-
-| 원본 모드 | 테이블 | 입력 | builder/code |
-|---|---|---|---|
-| 주요매출 구성 | `INPUT_PIE` | 종목, 기준일, 최대 5개 구성명·비율 | `s5076/5076` |
-| 성장성 지표 | `INPUT_GROW` | 매출·영업이익·순자산·순이익 증가율 각 4개와 분기 4개 | `s5079/5079` |
-| 매출액 | `INPUT_SELL` | 분기·금액 6쌍 | `s5080/5080` |
-| 영업이익 | `INPUT_PROFIT` | 분기·금액 6쌍 | `s5081/5081` |
-
-자동 근거는 `LegacyManualFinancialScreenServiceTests.cs`, `OracleManualFinancialMutationExecutorTests.cs`, `manual-financial-workflow.test.cjs`, `manual-financial-bridge-integration.test.cjs`다.
-
-### FSell — 수동 순매도 3종
-
-원본: `Form/FSell.cs`, `Form/FSell.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `FSell_Load`, `data_load`, `ReadListFile` | 개인·외국인·기관별 로컬 파일에서 5행, 행마다 좌/우 종목·금액을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs`의 store/data source; native `request-manual-net-sell-data`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 |
-| `btnok_Click`, `WriteListFile` | 정확히 5행을 저장하고 수동 순매도 항목 생성을 허용한다. | native `save-manual-net-sell-data`; `Web/manual-lists-workflow.js` | 화면 통합 완료. 검증된 versioned 데이터만 `s5025/5025`로 변환한다. 실제 파일 write는 자동 테스트 임시 디렉터리에서만 확인, 운영 데이터 write 없음. |
-| `btncn_Click` | 취소 시 placeholder 행을 유지하지 않는다. | workflow가 저장 성공·신뢰 데이터 없이는 항목을 만들지 않는다. | 화면 통합 완료. PGM 없음. |
-
-자동 근거는 `TrustedManualSceneDataLoader5025Tests.cs`, `S5025TrustedManualFileDataSourceTests.cs`, `S5025TrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`다.
-
-### VIList — 수동 VI 목록
-
-원본: `Form/VIList.cs`, `Form/VIList.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `VILIST_Load` | 로컬 VI 목록을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs`; native `request-vi-manual-list`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 |
-| 종목 검색, `listBox_DoubleClick` | 종목 master에서 코스피/코스닥 prefix와 종목명을 추가한다. | native `search-stocks`, `manual-lists-workflow.js`의 strict VI item | 화면 통합 완료. |
-| `btn1/2/3/4_Click` | 선택/전체 삭제와 위/아래 이동 | manual lists draft operations | 화면 통합 완료. |
-| `btnok_Click` | 목록을 저장하고 종목명을 comma로 결합해 항상 `5단 표그래프 / VI 발동(수동)`으로 추가하며 page를 5개 단위로 계산한다. | native `save-vi-manual-list`; trusted `s5074/5074`, 5개 단위 page entry | 화면 통합 완료. 자동 테스트는 `ViTrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`, fixed workflow의 VI placeholder 테스트. 운영 write 없음. |
-| `btncancel_Click` | 저장·추가 없이 닫는다. | workflow 취소 시 mutation 없음 | 화면 통합 완료. 수동 VI dataset의 PGM 없음. 기존 generic `5074` PGM 검증을 VI 입력 검증으로 간주하지 않는다. |
-
-### PList + AList — 이름 있는 DB 플레이리스트
-
-원본: `Form/PList.cs`, `Form/AList.cs`와 각 Designer
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 |
-| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. |
-| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. |
-| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. 실제 2,213행의 한국어 `DC_TITLE`/7필드 `LIST_TEXT` round-trip과 typed persistence는 전부 일치했다. KRX·해외종목·GraphE를 포함한 통합 read-only 증거는 `selectionMapped=1,603`, 차단 610이며 raw DB row는 scene data/asset/page를 재검증할 때까지 PREPARE 불가다. |
-| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. |
-| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
-
-구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, KRX/NXT/해외종목/manual 전용 restore, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트이며 실제 기존 데이터의 read-only 대조는 `docs/NAMED_PLAYLIST_READ_AUDIT.md`에 있다. 실제 운영 DB write와 named playlist 전용 scene/asset/PREPARE/PGM 검증은 아직 없다.
-
-### ThemeA — 테마 추가·편집
-
-원본: `Form/ThemeA.cs`, `Form/ThemeA.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `Theme_Load` | KRX/NXT 선택, 기존 테마 구성 종목과 순서를 로드한다. | native `request-theme-catalog-list`, `request-operator-catalog-schema`; `operator-catalog-workflow.js`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 |
-| `btnC_Click` | 시장별 테마명 중복을 확인한다. | native list/identity validation | 화면 통합 완료 |
-| 종목 검색·double-click | 같은 시장의 종목만 prefix가 포함된 구성 행으로 추가한다. | stock search + typed theme draft/preview | 화면 통합 완료 |
-| `btn1/2/3/4_Click`, Delete key | 선택/전체 삭제, 위/아래 이동 | operator catalog theme draft operations | 화면 통합 완료 |
-| `btnok_Click`, `GetNextCode` | 신규 코드를 만들거나 기존 `SB_LIST`/`SB_ITEM`을 교체하고 입력 순서를 저장한다. KRX와 NXT 저장소를 구분한다. | native `request-theme-catalog-next-code`, `create-theme-catalog`, `replace-theme-catalog`; atomic mutation executor | 화면 통합 완료. 실제 운영 DB-W/PGM 없음. |
-| UC4 테마 삭제 | `SB_ITEM` 후 `SB_LIST` 삭제 | native `delete-theme-catalog` | 화면 통합 완료. |
-
-구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`다. 자동 근거는 theme catalog, schema, executor, Web workflow 테스트다. expected identity와 응답 correlation 계약은 구현했지만 실제 운영 DB에서 KRX/NXT, 동명이름, 코드가 같은 선택 행을 정확히 replace/delete하는지는 검증 대기다.
-
-### EList — 전문가 신규 생성
-
-원본: `Form/EList.cs`, `Form/EList.Designer.cs`
-
-| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 |
-|---|---|---|---|
-| `EList_Load`, `GetNextCode` | `EXPERT_LIST`의 다음 4자리 코드를 제안한다. | native `request-expert-catalog-next-code`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 |
-| `btnok_Click` | 이름을 검증하고 `EXPERT_LIST`에 신규 전문가를 저장한다. | native `create-expert-catalog` | 화면 통합 완료. 실제 운영 DB-W 없음. |
-| UC6 저장·삭제 | 전문가와 `RECOMMEND_LIST` 구성 종목·금액·PLAYINDEX를 교체 또는 삭제한다. | native `replace-expert-catalog`, `delete-expert-catalog` | 화면 통합 완료. 실제 PGM 없음. |
-| `btncn_Click` | 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
-
-구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, 공통 schema/executor/native partial, `Web/operator-catalog-workflow.js`다. 자동 근거는 expert catalog, schema, executor, Web workflow 테스트다. 전문가 코드·이름과 추천 행 identity 계약은 구현했지만 실제 운영 DB의 create/replace/delete read-back은 검증 대기다.
-
-## bridge와 화면 로딩 상태 요약
-
-| 기능군 | native request | workflow | 현재 `index.html`/`app.js` |
-|---|---|---|---|
-| 종목/고정/업종/비교/테마 조회/해외/전문가 조회/거래정지 | `search-stocks`, `request-industries`, `search-themes`, `request-theme-preview`, `search-world-stocks`, `search-overseas-industries`, `search-experts`, `request-expert-preview`, `search-trading-halts` | 각 전용 workflow | 로드·연결됨 |
-| 이름 있는 플레이리스트 | `request-named-playlist-list`, `request-named-playlist-load`, `create-named-playlist`, `replace-named-playlist`, `delete-named-playlist` | `named-playlist-workflow.js` | 로드·연결됨 |
-| GraphE | `request-manual-financial-list`, `request-manual-financial-load`, `request-manual-financial-selection`, `create-manual-financial-record`, `update-manual-financial-record`, `delete-manual-financial-record`, `delete-all-manual-financial-records` | `manual-financial-workflow.js`, `manual-financial-ui.js` | 로드·연결됨 |
-| FSell/VI | `request-manual-operator-data-status`, `request-manual-net-sell-data`, `save-manual-net-sell-data`, `request-vi-manual-list`, `save-vi-manual-list` | `manual-lists-workflow.js`, `manual-lists-ui.js` | 로드·연결됨 |
-| ThemeA/EList CRUD | `request-theme-catalog-list`, `request-expert-catalog-list`, `request-operator-catalog-schema`, theme/expert next-code, create/replace/delete requests | `operator-catalog-workflow.js`, `operator-catalog-ui.js` | 로드·연결됨 |
-
-## 근거 파일 빠른 링크
-
-| 영역 | 구현 | 자동 테스트 |
+| 원본 이벤트 | 원본 의미 | LegacyParityApp 동작 |
|---|---|---|
-| 공통 화면·송출·배경 | [MainWindow](../MainWindow.xaml.cs), [Playout partial](../MainWindow.Playout.cs), [Background partial](../MainWindow.Background.cs), [Web app](../Web/app.js) | [playout safety](../tests/Web/playout-safety.test.cjs) |
-| 328 fixed / 44 industry / 31 stock / 9 comparison | [fixed workflow](../Web/legacy-fixed-workflow.js), [industry workflow](../Web/industry-workflow.js), [stock workflow](../Web/operator-workflow.js), [comparison workflow](../Web/comparison-workflow.js) | [fixed](../tests/Web/legacy-fixed-workflow.test.cjs), [industry](../tests/Web/industry-workflow.test.cjs), [stock](../tests/Web/operator-workflow.test.cjs), [comparison](../tests/Web/comparison-workflow.test.cjs) |
-| 테마 조회·해외·전문가 조회·거래정지 | [theme](../Web/theme-workflow.js), [overseas](../Web/overseas-workflow.js), [expert](../Web/expert-workflow.js), [trading halt](../Web/trading-halt-workflow.js) | [theme](../tests/Web/theme-workflow.test.cjs), [overseas](../tests/Web/overseas-workflow.test.cjs), [expert](../tests/Web/expert-workflow.test.cjs), [trading halt](../tests/Web/trading-halt-workflow.test.cjs) |
-| GraphE | [native partial](../MainWindow.ManualFinancial.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs), [workflow](../Web/manual-financial-workflow.js) | [Core](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs), [Web](../tests/Web/manual-financial-workflow.test.cjs), [bridge](../tests/Web/manual-financial-bridge-integration.test.cjs) |
-| FSell / VI | [native partial](../MainWindow.ManualLists.cs), [FSell store/data source](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs), [VI store](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs), [workflow](../Web/manual-lists-workflow.js) | [FSell store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs), [VI store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs), [Web](../tests/Web/manual-lists-workflow.test.cjs) |
-| PList / AList | [native partial](../MainWindow.NamedPlaylists.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs), [workflow](../Web/named-playlist-workflow.js) | [Core integration](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs), [Web](../tests/Web/named-playlist-workflow.test.cjs), [bridge](../tests/Web/named-playlist-bridge-integration.test.cjs) |
-| ThemeA / EList | [native partial](../MainWindow.OperatorCatalogs.cs), [theme persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs), [expert persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs), [executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs), [workflow](../Web/operator-catalog-workflow.js) | [theme](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs), [expert](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs), [Web](../tests/Web/operator-catalog-workflow.test.cjs) |
+| `Form1_Load` | 자산·DB·탭·Tornado 초기화 | WebView local UI와 C# composition root 초기화; 기본 `DryRun` |
+| `tabControl1_SelectedIndexChanged` | 탭별 최초 데이터 조회 | 10개 identity별 C# activation gate; 최초 진입 1회 load, 재진입 snapshot 유지 |
+| 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | identity 기반 drag/키보드 이동; active tab·playlist 불변 |
+| 검색 버튼/Enter | 종목 검색 | 원본 KOSPI→KOSDAQ→NXT 순서와 마지막 이름 일치 규칙 유지 |
+| 컷 click/double-click/drag | 원본 31컷 선택·추가 | plain/Ctrl/Shift/Ctrl+Shift와 원본 double-click 판정, batch 추가 |
+| playlist click/key/buttons | 선택, enabled, 이동, 삭제 | C# playlist authority; PREPARED/PROGRAM/pending/unknown에서 mutation 차단 |
+| F2/F3 | 배경 선택·없음 | trusted sibling background root만 허용; 누락·reparse·escape fail closed |
+| F8 | TAKE IN | idle이면 내부 PREPARE → TAKE IN을 한 번에 실행 |
+| NEXT | Page NEXT 또는 다음 행 | 같은 scene PageN 여부를 C#이 판정 |
+| Escape | TAKE OUT | modal이 없을 때만 StopAll 기반 TAKE OUT intent |
+| FormClosing | 종료 확인 | 기본 취소 확인창; 자동 TAKE OUT/반대 명령 없음 |
-## 실제 검증 판정
+## FarPoint 대체와 클릭 규칙
-| 항목 | 결과 |
-|---|---|
-| 자동 테스트 | Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 415/415도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. |
-| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. |
-| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. |
-| PList legacy 복원 | 실제 `DC_TITLE`/`LIST_TEXT` 2,213행의 한국어·7필드 무손상과 typed persistence 일치를 확인했다. 기본 `selectionMapped=1,581`/차단 632와 별도 GraphE 22/14를 합친 결과는 1,603/610이다. selection 매핑은 scene data·asset·PREPARE·PGM 완료 증거가 아니다. |
-| manual named save | 현재 legacy PList manual 36행은 모두 GraphE이며 fresh proof로 22행을 복원하고 저장값 무효 9행·identity 중복 5행을 차단했다. FSell/VI PList행은 0건이고 production source read는 FSell 3×5행, VI 9항목·2 page로 전후 불변이다. 실제 Oracle save→reload와 PGM은 미검증이다. |
-| ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. |
-| legacy file import | `종목비교.dat`는 signed package에서 8쌍 1회 실행·재시작 marker까지 확인했다. FSell·VI importer는 strict parser, source/hash 재검증, durable intent/marker와 격리 fixture를 통과했으며 현재 운영 profile은 기존 동일 파일 때문에 no-overwrite 차단된다. |
-| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 signed `1.0.5.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 1.0.5 package UI에서 실제 삼성전자 검색·현재가 5001 DryRun과 자동 refresh cap을 확인했으며, 기존 10개 업무 탭 상태·비교 8쌍 영속성 증거는 1.0.4 감사에 보존한다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·rollback 회차에서만 수행한다. |
+FarPoint UI는 Web table/list로 표현한다. 다음 의미는 원본과 같이 유지한다.
+
+- plain/Ctrl/Shift/Ctrl+Shift 선택과 현재 행
+- column header 정렬과 현재 표시 순서
+- 위/아래 이동, 선택/전체 삭제와 enabled 토글
+- single-click과 double-click을 컨트롤별로 구분
+- 부모 section 전체 추가는 모든 자식을 먼저 materialize한 뒤 한 번에 commit
+- 수동/지원 불가 child가 있으면 부분 추가 없이 전체 차단
+- modal이 열리면 F2/F3/F8/Escape, Delete/Space/Home/End 등 전역 동작 격리
+- stale generation, 예상하지 않은 row id, 추가 payload field를 strict bridge에서 거부
+
+GraphE 행 double-click은 fresh DB record를 다시 확인한 뒤 한 행을 추가하고 닫는다.
+Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PList 행 double-click은
+현재 save/load 모드에 맞춰 동작하며, 이름만으로 DB identity를 추정하지 않는다.
+
+## 원클릭 TAKE IN과 숨김 PREPARE
+
+원본 운영 화면의 핵심 동작은 별도 PREPARE를 먼저 누르는 것이 아니라 F8/TAKE IN 한 번으로
+송출하는 것이다. 현재 보이는 TAKE IN은 idle에서도 활성 조건을 만족하면 다음을 실행한다.
+
+1. playlist selection과 page를 C# snapshot으로 고정
+2. DB/신뢰 파일 DTO와 PageN을 fresh load
+3. 장면 mutation과 transaction 순서를 적용해 `Prepare` 정확히 1회
+4. 성공한 prepared scene에 `TakeIn` 정확히 1회
+
+엔지니어링용 native PREPARE control과 explicit workflow는 테스트를 위해 유지하지만 Web UI에서
+`hidden`이다. TAKE IN 실패·timeout·`OutcomeUnknown`이면 자동 재시도하지 않으며 성공한
+prepared ownership을 추측해 지우지 않는다.
+
+## PageN과 NEXT
+
+| scene | page size | 규칙 |
+|---|---:|---|
+| `s5074` | 5 | 테마/전문가/VI 등 5행 경로 |
+| `s5077` | 6 | 6종목 테마 경로 |
+| `s5088` | 12 | 12종목 테마 경로 |
+
+`pageCount = min(20, ceil(itemCount / pageSize))`를 사용한다. 0행, 정확한 경계,
+마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부, 같은 scene의 `GetPlayingScene`
+Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다.
+
+## 개발 DB write 증적
+
+실제 개발 Oracle/MariaDB에서 production executor를 사용한 9개 충돌 격리 시나리오가
+create/save → fresh readback → delete → absence를 통과했다.
+
+| 기능 | 시나리오 |
+|---|---:|
+| GraphE `INPUT_PIE/GROW/SELL/PROFIT` | 4 |
+| PList/AList named playlist | 2 |
+| ThemeA KRX/NXT | 2 |
+| EList expert | 1 |
+| 합계 | **9** |
+
+이는 개발 DB 통합 증거이며 운영 DB write를 수행했다는 뜻이 아니다. write 재시도는 없고,
+결과가 불명확하면 cleanup과 다음 시나리오도 실행하지 않는 fail-closed 규칙을 유지한다.
+
+## read-only·scene·asset 증적
+
+- 실제 Oracle/MariaDB 필수 scene selector **33/33**, 전체 query 58건 PASS
+- 원본 scene builder 자동 기준선 **35/35**
+- MainForm 도달 runtime 34개 builder와 active alias **45개**
+- 승인된 Cuts에 45 alias 대응 `.t2s` 존재
+- `s5006` `Video\큐브배경.vrv` 1개와 `s6001` 국가 영상 13개는 외부자산 미제공
+- sibling `배경\기본.vrv`도 외부자산 미제공
+
+마지막 14개 영상과 `기본.vrv`의 부재는 UI/scene 코드 실패가 아니다. 해당 기능은 자산이
+제공될 때까지 COM 전에 차단하며 임의 파일로 대체하지 않는다.
+
+## 구현·테스트 빠른 링크
+
+| 영역 | 현재 실행 구현 | 주요 테스트/근거 |
+|---|---|---|
+| WinUI 3 + WebView2 shell | [MainWindow.xaml.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs), [Web app](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js), [Web layout](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html) | [thin client](../tests/LegacyParityWeb/thin-client.test.cjs) |
+| C# 업무 권위 | [LegacyOperatorController.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs) | [LegacyApplication tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests) |
+| strict bridge | [LegacyBridgeProtocol.cs](../src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs) | [LegacyBridge tests](../tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests) |
+| 송출 | [MainWindow.Playout.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs), [playout-ui.js](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js), [LegacyPlayoutWorkflow.cs](../src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs) | [LegacyPlayoutWorkflowTests.cs](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs) |
+| 수동 목록 | [MainWindow.ManualLists.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs), [LegacyManualListsWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs) | [manual-list tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualLists) |
+| GraphE | [LegacyManualFinancialWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs) | [manual-financial tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial) |
+| PList/AList | [LegacyNamedPlaylistWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs) | [named-playlist tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist) |
+| ThemeA/EList | [LegacyOperatorCatalogWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs) | [catalog tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/OperatorCatalog) |
+| scene matrix | [SCENE_EQUIVALENCE.md](SCENE_EQUIVALENCE.md) | 35/35 builder, 45 alias, PageN matrix |
+| 2026-07-15 증적 | [LEGACY_PARITY_VALIDATION_20260715.md](LEGACY_PARITY_VALIDATION_20260715.md) | 최종 전체 회귀 기록란 포함 |
+
+최종 Debug/Release 전체 테스트 개수, 새 MSIX SHA-256와 개발 PGM 결과는 병렬 변경을
+모두 합친 뒤 위 검증 문서에 기록했다. 이전 package 숫자나 hash를 현재 결과로
+재사용하지 않았다.
diff --git a/scripts/Export-LegacyFixedCatalog.cjs b/scripts/Export-LegacyFixedCatalog.cjs
new file mode 100644
index 0000000..86d4f2a
--- /dev/null
+++ b/scripts/Export-LegacyFixedCatalog.cjs
@@ -0,0 +1,56 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const repositoryRoot = path.resolve(__dirname, "..");
+const sourcePath = path.join(repositoryRoot, "Web", "legacy-fixed-workflow.js");
+const outputPath = path.join(
+ repositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.LegacyApplication",
+ "Catalog",
+ "LegacyFixedActions.json");
+
+const sourceBytes = fs.readFileSync(sourcePath);
+const workflow = require(sourcePath);
+if (!Array.isArray(workflow.actions) || workflow.actions.length !== 328) {
+ throw new Error("The fixed legacy catalog must contain exactly 328 actions.");
+}
+
+const actions = workflow.actions.map((action, index) => {
+ const expectedId = `fixed-${String(index + 1).padStart(3, "0")}`;
+ if (action.id !== expectedId || !workflow.fixedMarkets.includes(action.market)) {
+ throw new Error(`Unexpected fixed action identity at index ${index}.`);
+ }
+
+ return {
+ id: action.id,
+ market: action.market,
+ section: action.section,
+ label: action.label,
+ detail: action.detail,
+ category: action.category,
+ builderKey: action.builderKey,
+ code: action.code,
+ aliases: action.aliases,
+ available: action.available,
+ prerequisite: action.prerequisite,
+ selection: action.selection,
+ dynamicDate: action.dynamicDate,
+ dynamicNxtSession: action.dynamicNxtSession
+ };
+});
+
+const document = {
+ schemaVersion: 1,
+ generatedFrom: "Web/legacy-fixed-workflow.js",
+ sourceSha256: crypto.createHash("sha256").update(sourceBytes).digest("hex").toUpperCase(),
+ runtimeAssets: workflow.runtimeAssets,
+ actions
+};
+
+fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+fs.writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
+process.stdout.write(`${outputPath}\n`);
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
index 7b4f4b8..b67729a 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
@@ -1054,13 +1054,14 @@ internal static class ManualFinancialValidation
new ManualRevenueCompositionRecord(
identity,
Text(
- RequiredString(row, "BASE_DATE"),
+ OptionalOracleString(row, "BASE_DATE"),
MaximumTextLength,
allowEmpty: true,
allowUnderscore: true,
nameof(row)),
Enumerable.Range(1, 5)
- .Select(index => ParseSlice(RequiredString(row, "GUSUNG_" + index)))
+ .Select(index => ParseSlice(
+ OptionalOracleString(row, "GUSUNG_" + index)))
.ToArray()),
ManualFinancialScreenKind.GrowthMetrics => ParseGrowth(identity, row),
ManualFinancialScreenKind.Sales =>
@@ -1324,6 +1325,23 @@ internal static class ManualFinancialValidation
return value;
}
+ private static string OptionalOracleString(DataRow row, string columnName)
+ {
+ if (!row.Table.Columns.Contains(columnName))
+ {
+ throw new ArgumentException("A manual-financial database value is invalid.", nameof(row));
+ }
+
+ return row[columnName] switch
+ {
+ string value => value,
+ DBNull => string.Empty,
+ _ => throw new ArgumentException(
+ "A manual-financial database value is invalid.",
+ nameof(row))
+ };
+ }
+
private static string RowVersion(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
index 54dbf5c..bcf5a09 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
@@ -293,6 +293,72 @@ public sealed class LegacyPlayoutWorkflow
}
}
+ ///
+ /// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and
+ /// prepares the selected scene, then Play starts that exact prepared instance. The
+ /// page DTO is therefore loaded once and the workflow gate covers both engine calls.
+ /// This is intentionally separate from , which preserves the
+ /// legacy explicit-PREPARE path and refreshes its page before play.
+ ///
+ public async Task PrepareAndTakeInAsync(
+ IReadOnlyList playlist,
+ int cueIndexZeroBased,
+ CancellationToken cancellationToken = default)
+ {
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_prepared is not null || _onAir is not null)
+ {
+ return Rejected(
+ PlayoutOperation.TakeIn,
+ "One-click TAKE IN can start only while the playout workflow is idle.");
+ }
+
+ var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
+ var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
+ if (selectedIndex is null)
+ {
+ return Rejected(
+ PlayoutOperation.Prepare,
+ "There is no enabled playlist entry at or after the selection.");
+ }
+
+ var page = await CreatePageAsync(
+ snapshot,
+ selectedIndex.Value,
+ pageIndexZeroBased: 0,
+ cancellationToken).ConfigureAwait(false);
+ var prepare = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
+ .ConfigureAwait(false);
+ if (!prepare.IsSuccess)
+ {
+ return prepare;
+ }
+
+ // Once PREPARE succeeds the engine owns this scene. Keep that prepared
+ // state if TAKE IN fails or its outcome is unknown; never reload or retry.
+ _playlist = snapshot;
+ _prepared = page;
+ PublishState();
+
+ var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
+ if (takeIn.IsSuccess)
+ {
+ _onAir = page;
+ _prepared = null;
+ PublishState();
+ }
+
+ return takeIn;
+ }
+ finally
+ {
+ ApplyRequestedEngineClear();
+ _gate.Release();
+ }
+ }
+
public async Task TakeInAsync(
CancellationToken cancellationToken = default)
{
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json
new file mode 100644
index 0000000..d2fe453
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json
@@ -0,0 +1,7893 @@
+{
+ "schemaVersion": 1,
+ "generatedFrom": "Web/legacy-fixed-workflow.js",
+ "sourceSha256": "31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5",
+ "runtimeAssets": {
+ "overseas": {
+ "relativePath": "bin/Debug/Res/해외.ini",
+ "sha256": "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"
+ },
+ "exchange": {
+ "relativePath": "bin/Debug/Res/환율.ini",
+ "sha256": "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"
+ },
+ "index": {
+ "relativePath": "bin/Debug/Res/지수.ini",
+ "sha256": "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6"
+ }
+ },
+ "actions": [
+ {
+ "id": "fixed-001",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "다우",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "Dow",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-002",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "나스닥",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "Nasdaq",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-003",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "S&P",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "Sp500",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-004",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "독일",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "GermanyDax",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-005",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "영국",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "UnitedKingdomFtse",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-006",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "프랑스",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "FranceCac",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-007",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "니케이",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "Nikkei",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-008",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "중국 상해",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "ShanghaiComposite",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-009",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "홍콩 항셍",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "HangSeng",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-010",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "대만 가권",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "TaiwanWeighted",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-011",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "싱가포르 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "SingaporeStraitsTimes",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-012",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "태국 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "ThailandSet",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-013",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "필리핀 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "PhilippinesComposite",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-014",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "말레이시아 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "MalaysiaKlse",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-015",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "인도네시아 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "IndonesiaComposite",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-016",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "BDI 지수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "BALTIC_DRY_INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-017",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "소맥",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "FeedWheat",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-018",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "옥수수",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Corn",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-019",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "밀",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Wheat",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-020",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "대두",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Soybean",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-021",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "콩",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Bean",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-022",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "현미",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "BrownRice",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-023",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "커피",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Coffee",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-024",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "코코아",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Cocoa",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-025",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "설탕",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Sugar",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-026",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "생우(소)",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "LiveCattle",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-027",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "비육우",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "FeederCattle",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-028",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "돈육(돼지)",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "LeanHogs",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-029",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "구리",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Copper",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-030",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "철광석",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "IronOre",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-031",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "니켈",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Nickel",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-032",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "천연가스",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "NaturalGas",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-033",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "백금",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Platinum",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-034",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "팔라듐",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Palladium",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-035",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "납",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Lead",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-036",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "아연",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Zinc",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-037",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "주석",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Tin",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-038",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "원면",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "RawCotton",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-039",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "목화(면화)",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "Cotton",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-040",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "국제 금",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "InternationalGold",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-041",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "국제 은",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "InternationalSilver",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-042",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "국내 금",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "DomesticGold",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-043",
+ "market": "overseas",
+ "section": "1열판기본",
+ "label": "국내 은",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "COMMODITY",
+ "subject": "DomesticSilver",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-044",
+ "market": "overseas",
+ "section": "2열판-",
+ "label": "농산물[원면, 목화(면화)]",
+ "detail": "2열판-",
+ "category": "plate",
+ "builderKey": "s50160",
+ "code": "50160",
+ "aliases": [
+ "50160"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "2열판-",
+ "subtype": "Cotton",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-045",
+ "market": "overseas",
+ "section": "2열판-",
+ "label": "원자재[국제 금, 국제 은]",
+ "detail": "2열판-",
+ "category": "plate",
+ "builderKey": "s50160",
+ "code": "50160",
+ "aliases": [
+ "50160"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "2열판-",
+ "subtype": "InternationalPreciousMetals",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-046",
+ "market": "overseas",
+ "section": "2열판-",
+ "label": "원자재[국내 금, 국내 은]",
+ "detail": "2열판-",
+ "category": "plate",
+ "builderKey": "s50160",
+ "code": "50160",
+ "aliases": [
+ "50160"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "2열판-",
+ "subtype": "DomesticPreciousMetals",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-047",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "미국 지수[다우, 나스닥, S&P]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "UnitedStatesIndices",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-048",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "중화권 지수[홍콩항셍, 상해종합, 대만]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "GreaterChinaIndices",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-049",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "유럽권 지수[영국, 프랑스, 독일]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "EuropeanIndices",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-050",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "아시아 지수[상해, 대만, 일본]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "AsianIndices",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-051",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "미국국채[2년, 3년, 5년]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "UnitedStatesTreasuries",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-052",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "채권금리(CD,CP,콜금리)",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "BondYields",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-053",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "유가[두바이, 브렌트유, WTI]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "Oil",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-054",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "광물[금, 은, 구리]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "Minerals",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-055",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "식자재[대두, 옥수수, 밀]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "FoodMaterials",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-056",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "농산물[소맥, 옥수수, 밀]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "AgricultureWheatCorn",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-057",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "농산물[대두, 콩, 현미]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "AgricultureSoyRice",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-058",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "농산물[커피, 코코아, 설탕]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "AgricultureCoffeeCocoaSugar",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-059",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "농산물[생우(소), 비육우, 돈육(돼지)]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "AgricultureLivestock",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-060",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "원자재[구리, 철광석, 니켈]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "RawMaterialsCopperIronNickel",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-061",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "원자재[천연가스, 백금, 팔라듐]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "RawMaterialsGasPlatinumPalladium",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-062",
+ "market": "overseas",
+ "section": "3열판",
+ "label": "원자재[납, 아연, 주석]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "RawMaterialsLeadZincTin",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-063",
+ "market": "overseas",
+ "section": "글로벌 증시 지도",
+ "label": "글로벌 증시 지도",
+ "detail": "글로벌 증시 지도",
+ "category": "market",
+ "builderKey": "s8067",
+ "code": "8067",
+ "aliases": [
+ "8067"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "WORLD_MAP",
+ "subtype": "8067",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-064",
+ "market": "overseas",
+ "section": "글로벌 증시 지도",
+ "label": "아시아 증시 지도",
+ "detail": "글로벌 증시 지도",
+ "category": "market",
+ "builderKey": "s8067",
+ "code": "5072",
+ "aliases": [
+ "5072"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "WORLD_MAP",
+ "subtype": "5072",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-065",
+ "market": "overseas",
+ "section": "글로벌 증시 지도",
+ "label": "미국 증시 지도",
+ "detail": "글로벌 증시 지도",
+ "category": "market",
+ "builderKey": "s8067",
+ "code": "5068",
+ "aliases": [
+ "5068"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "WORLD_MAP",
+ "subtype": "5068",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-066",
+ "market": "overseas",
+ "section": "글로벌 증시 지도",
+ "label": "유럽 증시 지도",
+ "detail": "글로벌 증시 지도",
+ "category": "market",
+ "builderKey": "s8067",
+ "code": "5070",
+ "aliases": [
+ "5070"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "WORLD_MAP",
+ "subtype": "5070",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-067",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "미국 다우 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "미국 다우 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "DowJones",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-068",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "미국 나스닥 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "미국 나스닥 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "Nasdaq",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-069",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "미국 S&P 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "미국 S&P 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "StandardAndPoor500",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-070",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "중국 상해 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "중국 상해 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "China",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-071",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "일본 닛케이 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "일본 닛케이 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "Japan",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-072",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "유가 두바이 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "유가 두바이 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "DubaiCrude",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-073",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "유가 WTI 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "유가 WTI 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "WtiCrude",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-074",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "유가 브렌트유 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "유가 브렌트유 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "BrentCrude",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-075",
+ "market": "overseas",
+ "section": "이미지 그래프",
+ "label": "금 지수",
+ "detail": "이미지 그래프",
+ "category": "market",
+ "builderKey": "s6001",
+ "code": "6001",
+ "aliases": [
+ "6001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "금 지수",
+ "graphicType": "IMAGE_GRAPH",
+ "subtype": "Gold",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-076",
+ "market": "overseas",
+ "section": "미국업종",
+ "label": "다우",
+ "detail": "미국업종",
+ "category": "chart",
+ "builderKey": "s5078",
+ "code": "5078",
+ "aliases": [
+ "5078"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "DOW",
+ "graphicType": "US_SECTOR_INDEX",
+ "subtype": "",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-077",
+ "market": "overseas",
+ "section": "미국업종",
+ "label": "나스닥",
+ "detail": "미국업종",
+ "category": "chart",
+ "builderKey": "s5078",
+ "code": "5078",
+ "aliases": [
+ "5078"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "NASDAQ",
+ "graphicType": "US_SECTOR_INDEX",
+ "subtype": "",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-078",
+ "market": "overseas",
+ "section": "미국업종",
+ "label": "S&P",
+ "detail": "미국업종",
+ "category": "chart",
+ "builderKey": "s5078",
+ "code": "5078",
+ "aliases": [
+ "5078"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FOREIGN_INDEX",
+ "subject": "S&P500",
+ "graphicType": "US_SECTOR_INDEX",
+ "subtype": "",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-079",
+ "market": "exchange",
+ "section": "1열판기본",
+ "label": "원달러",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-080",
+ "market": "exchange",
+ "section": "1열판기본",
+ "label": "원엔",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-081",
+ "market": "exchange",
+ "section": "1열판기본",
+ "label": "원위엔",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-082",
+ "market": "exchange",
+ "section": "1열판기본",
+ "label": "원유로",
+ "detail": "1열판기본",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-083",
+ "market": "exchange",
+ "section": "3열판",
+ "label": "해외환율[엔달러, 위안화달러, 유로달러]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "OverseasExchangeRates",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-084",
+ "market": "exchange",
+ "section": "3열판",
+ "label": "환율[원달러, 원엔, 원유로]",
+ "detail": "3열판",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "DomesticExchangeRates",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-085",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원달러_5일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-086",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원달러_20일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-087",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원달러_60일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-088",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원달러_120일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-089",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원달러_240일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-090",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원엔_5일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-091",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원엔_20일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-092",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원엔_60일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-093",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원엔_120일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-094",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원엔_240일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-095",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원위엔_5일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-096",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원위엔_20일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-097",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원위엔_60일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-098",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원위엔_120일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-099",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원위엔_240일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-100",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원유로_5일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-101",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원유로_20일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-102",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원유로_60일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-103",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원유로_120일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-104",
+ "market": "exchange",
+ "section": "수익률 그래프",
+ "label": "원유로_240일",
+ "detail": "수익률 그래프",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-105",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원달러_5일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-106",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원달러_20일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-107",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원달러_60일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-108",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원달러_120일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-109",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원달러_240일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonDollar",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-110",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원엔_5일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-111",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원엔_20일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-112",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원엔_60일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-113",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원엔_120일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-114",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원엔_240일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYen",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-115",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원위엔_5일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-116",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원위엔_20일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-117",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원위엔_60일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-118",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원위엔_120일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-119",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원위엔_240일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonYuan",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-120",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원유로_5일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-121",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원유로_20일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-122",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원유로_60일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-123",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원유로_120일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-124",
+ "market": "exchange",
+ "section": "라인 그래프",
+ "label": "원유로_240일",
+ "detail": "라인 그래프",
+ "category": "chart",
+ "builderKey": "s50860",
+ "code": "50860",
+ "aliases": [
+ "50860"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "EXCHANGE_RATE",
+ "subject": "WonEuro",
+ "graphicType": "LINE_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-125",
+ "market": "index",
+ "section": "주요지수",
+ "label": "3열판_주요지수[코스피, 코스닥, 코스피200]",
+ "detail": "주요지수",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "MajorKospiKosdaqKospi200",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-126",
+ "market": "index",
+ "section": "주요지수",
+ "label": "3열판_주요지수[코스피, 코스닥, 선물]",
+ "detail": "주요지수",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "MajorKospiKosdaqFutures",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-127",
+ "market": "index",
+ "section": "주요지수",
+ "label": "3열판_예상체결지수[코스피, 코스닥, 코스피200]",
+ "detail": "주요지수",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "ExpectedKospiKosdaqKospi200",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-128",
+ "market": "index",
+ "section": "주요지수",
+ "label": "3열판_예상체결지수[코스피, 코스닥, 선물]",
+ "detail": "주요지수",
+ "category": "plate",
+ "builderKey": "s5016",
+ "code": "5016",
+ "aliases": [
+ "5016"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "3열판",
+ "subtype": "ExpectedKospiKosdaqFutures",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-129",
+ "market": "index",
+ "section": "코스피",
+ "label": "1열판기본_예상지수",
+ "detail": "코스피",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "EXPECTED_INDEX",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-130",
+ "market": "index",
+ "section": "코스피",
+ "label": "1열판기본_현재지수",
+ "detail": "코스피",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-131",
+ "market": "index",
+ "section": "코스피",
+ "label": "수익률그래프_5일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-132",
+ "market": "index",
+ "section": "코스피",
+ "label": "수익률그래프_20일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-133",
+ "market": "index",
+ "section": "코스피",
+ "label": "수익률그래프_60일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-134",
+ "market": "index",
+ "section": "코스피",
+ "label": "수익률그래프_120일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-135",
+ "market": "index",
+ "section": "코스피",
+ "label": "수익률그래프_240일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-136",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_일봉",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-137",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_5일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-138",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_20일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-139",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_60일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-140",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_120일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-141",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프_240일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-142",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_일봉",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-143",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_5일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-144",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_20일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-145",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_60일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-146",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_120일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-147",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(거래량)_240일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-148",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(예상지수)_5일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-149",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(예상지수)_20일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-150",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(예상지수)_60일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-151",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(예상지수)_120일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-152",
+ "market": "index",
+ "section": "코스피",
+ "label": "캔들그래프(예상지수)_240일",
+ "detail": "코스피",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-153",
+ "market": "index",
+ "section": "코스닥",
+ "label": "1열판기본_예상지수",
+ "detail": "코스닥",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "EXPECTED_INDEX",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-154",
+ "market": "index",
+ "section": "코스닥",
+ "label": "1열판기본_현재지수",
+ "detail": "코스닥",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-155",
+ "market": "index",
+ "section": "코스닥",
+ "label": "수익률그래프_5일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-156",
+ "market": "index",
+ "section": "코스닥",
+ "label": "수익률그래프_20일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-157",
+ "market": "index",
+ "section": "코스닥",
+ "label": "수익률그래프_60일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-158",
+ "market": "index",
+ "section": "코스닥",
+ "label": "수익률그래프_120일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-159",
+ "market": "index",
+ "section": "코스닥",
+ "label": "수익률그래프_240일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-160",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_일봉",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-161",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_5일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-162",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_20일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-163",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_60일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-164",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_120일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-165",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프_240일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-166",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_일봉",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-167",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_5일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-168",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_20일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-169",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_60일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-170",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_120일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-171",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(거래량)_240일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-172",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(예상지수)_5일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-173",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(예상지수)_20일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-174",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(예상지수)_60일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-175",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(예상지수)_120일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-176",
+ "market": "index",
+ "section": "코스닥",
+ "label": "캔들그래프(예상지수)_240일",
+ "detail": "코스닥",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-177",
+ "market": "index",
+ "section": "코스피200",
+ "label": "1열판기본_예상지수",
+ "detail": "코스피200",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "EXPECTED_INDEX",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-178",
+ "market": "index",
+ "section": "코스피200",
+ "label": "1열판기본_현재지수",
+ "detail": "코스피200",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-179",
+ "market": "index",
+ "section": "코스피200",
+ "label": "수익률그래프_5일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-180",
+ "market": "index",
+ "section": "코스피200",
+ "label": "수익률그래프_20일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-181",
+ "market": "index",
+ "section": "코스피200",
+ "label": "수익률그래프_60일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-182",
+ "market": "index",
+ "section": "코스피200",
+ "label": "수익률그래프_120일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-183",
+ "market": "index",
+ "section": "코스피200",
+ "label": "수익률그래프_240일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-184",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_일봉",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-185",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_5일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-186",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_20일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-187",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_60일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-188",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_120일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-189",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프_240일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-190",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_일봉",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-191",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_5일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-192",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_20일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-193",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_60일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-194",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_120일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-195",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(거래량)_240일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-196",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(예상지수)_5일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-197",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(예상지수)_20일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-198",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(예상지수)_60일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-199",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(예상지수)_120일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-200",
+ "market": "index",
+ "section": "코스피200",
+ "label": "캔들그래프(예상지수)_240일",
+ "detail": "코스피200",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI200_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-201",
+ "market": "index",
+ "section": "선물",
+ "label": "1열판기본_예상지수",
+ "detail": "선물",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "EXPECTED_INDEX",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-202",
+ "market": "index",
+ "section": "선물",
+ "label": "1열판기본_현재지수",
+ "detail": "선물",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-203",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_일봉",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-204",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_5일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-205",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_20일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-206",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_60일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-207",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_120일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-208",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프_240일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-209",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프(예상지수)_5일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-210",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프(예상지수)_20일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-211",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프(예상지수)_60일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-212",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프(예상지수)_120일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-213",
+ "market": "index",
+ "section": "선물",
+ "label": "캔들그래프(예상지수)_240일",
+ "detail": "선물",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "FUTURES_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-214",
+ "market": "index",
+ "section": "KRX100",
+ "label": "1열판기본_예상지수",
+ "detail": "KRX100",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "EXPECTED_INDEX",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-215",
+ "market": "index",
+ "section": "KRX100",
+ "label": "1열판기본_현재지수",
+ "detail": "KRX100",
+ "category": "plate",
+ "builderKey": "s5001",
+ "code": "5001",
+ "aliases": [
+ "5001"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "1열판기본",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-216",
+ "market": "index",
+ "section": "KRX100",
+ "label": "수익률그래프_5일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "FiveDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-217",
+ "market": "index",
+ "section": "KRX100",
+ "label": "수익률그래프_20일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-218",
+ "market": "index",
+ "section": "KRX100",
+ "label": "수익률그래프_60일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "SixtyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-219",
+ "market": "index",
+ "section": "KRX100",
+ "label": "수익률그래프_120일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "OneHundredTwentyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-220",
+ "market": "index",
+ "section": "KRX100",
+ "label": "수익률그래프_240일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s5086",
+ "code": "5086",
+ "aliases": [
+ "5086"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100",
+ "subject": "INDEX",
+ "graphicType": "YIELD_GRAPH",
+ "subtype": "TwoHundredFortyDays",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-221",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_일봉",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-222",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_5일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-223",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_20일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-224",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_60일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-225",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_120일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-226",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프_240일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "PRICE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-227",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_일봉",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8035",
+ "aliases": [
+ "8035"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-228",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_5일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8061",
+ "aliases": [
+ "8061"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-229",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_20일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8040",
+ "aliases": [
+ "8040"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-230",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_60일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8046",
+ "aliases": [
+ "8046"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-231",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_120일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8051",
+ "aliases": [
+ "8051"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-232",
+ "market": "index",
+ "section": "KRX100",
+ "label": "캔들그래프(거래량)_240일",
+ "detail": "KRX100",
+ "category": "chart",
+ "builderKey": "s8010",
+ "code": "8056",
+ "aliases": [
+ "8056"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KRX100_INDEX",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-233",
+ "market": "index",
+ "section": "매매동향",
+ "label": "주체별 매매동향_표그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5082",
+ "code": "5082",
+ "aliases": [
+ "5082"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "TRADING_BY_PARTICIPANT",
+ "subtype": "TABLE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-234",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스피 매매동향_표그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5023",
+ "code": "5023",
+ "aliases": [
+ "5023"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "KOSPI_TRADING_TREND",
+ "subtype": "TABLE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-235",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스피 매매동향_라인그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5084",
+ "code": "5084",
+ "aliases": [
+ "5084"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "KOSPI_TRADING_TREND",
+ "subtype": "LINE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-236",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스피 매매동향_막대그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5024",
+ "code": "5024",
+ "aliases": [
+ "5024"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSPI",
+ "subject": "INDEX",
+ "graphicType": "KOSPI_TRADING_TREND",
+ "subtype": "BAR_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-237",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스닥 매매동향_표그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5023",
+ "code": "5023",
+ "aliases": [
+ "5023"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "KOSDAQ_TRADING_TREND",
+ "subtype": "TABLE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-238",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스닥 매매동향_라인그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5084",
+ "code": "5084",
+ "aliases": [
+ "5084"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "KOSDAQ_TRADING_TREND",
+ "subtype": "LINE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-239",
+ "market": "index",
+ "section": "매매동향",
+ "label": "코스닥 매매동향_막대그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5024",
+ "code": "5024",
+ "aliases": [
+ "5024"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "KOSDAQ_TRADING_TREND",
+ "subtype": "BAR_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-240",
+ "market": "index",
+ "section": "매매동향",
+ "label": "개인 매매동향_라인그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5083",
+ "code": "5083",
+ "aliases": [
+ "5083"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "INDIVIDUAL_TRADING_TREND",
+ "subtype": "LINE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-241",
+ "market": "index",
+ "section": "매매동향",
+ "label": "외국인 매매동향_라인그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5083",
+ "code": "5083",
+ "aliases": [
+ "5083"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "FOREIGN_TRADING_TREND",
+ "subtype": "LINE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-242",
+ "market": "index",
+ "section": "매매동향",
+ "label": "기관 매매동향_라인그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5083",
+ "code": "5083",
+ "aliases": [
+ "5083"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "INSTITUTION_TRADING_TREND",
+ "subtype": "LINE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-243",
+ "market": "index",
+ "section": "매매동향",
+ "label": "기관 순매수 현황_표그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s6067",
+ "code": "6067",
+ "aliases": [
+ "6067"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "INSTITUTION_NET_BUY",
+ "subtype": "TABLE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-244",
+ "market": "index",
+ "section": "매매동향",
+ "label": "프로그램 매매_표그래프",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5085",
+ "code": "5085",
+ "aliases": [
+ "5085"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "ALL",
+ "subject": "INDEX",
+ "graphicType": "PROGRAM_TRADING",
+ "subtype": "TABLE_GRAPH",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-245",
+ "market": "index",
+ "section": "매매동향",
+ "label": "개인 순매도 상위(수동)",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5025",
+ "code": "5025",
+ "aliases": [
+ "5025"
+ ],
+ "available": false,
+ "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "INDIVIDUAL",
+ "subject": "INDEX",
+ "graphicType": "MANUAL_NET_SELL",
+ "subtype": "MANUAL_NET_SELL",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-246",
+ "market": "index",
+ "section": "매매동향",
+ "label": "외국인 순매도 상위(수동)",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5025",
+ "code": "5025",
+ "aliases": [
+ "5025"
+ ],
+ "available": false,
+ "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "FOREIGN",
+ "subject": "INDEX",
+ "graphicType": "MANUAL_NET_SELL",
+ "subtype": "MANUAL_NET_SELL",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-247",
+ "market": "index",
+ "section": "매매동향",
+ "label": "기관 순매도 상위(수동)",
+ "detail": "매매동향",
+ "category": "market",
+ "builderKey": "s5025",
+ "code": "5025",
+ "aliases": [
+ "5025"
+ ],
+ "available": false,
+ "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "INSTITUTION",
+ "subject": "INDEX",
+ "graphicType": "MANUAL_NET_SELL",
+ "subtype": "MANUAL_NET_SELL",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-248",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 시가총액",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-249",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 예상가 시가총액 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-250",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-251",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-252",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 시간외 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-253",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 시간외 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-254",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 시가총액",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-255",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 예상가 시가총액 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-256",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-257",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-258",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 시간외 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-259",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥 시간외 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-260",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피 200 종목",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "KOSPI200",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-261",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "ETF 종목",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "ETF",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-262",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "VI 발동(수동)",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": false,
+ "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "PAGED_VI",
+ "subject": "",
+ "graphicType": "INPUT_ORDER",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-263",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 시가총액",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-264",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-265",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-266",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 시간외 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-267",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 시간외 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-268",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스피_NXT 거래량 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-269",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 시가총액",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-270",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-271",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-272",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 시간외 상승률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-273",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 시간외 하락률 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-274",
+ "market": "index",
+ "section": "5단 표그래프",
+ "label": "코스닥_NXT 거래량 상위",
+ "detail": "5단 표그래프",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-275",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 시가총액",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-276",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 예상가 시가총액 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-277",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-278",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-279",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 52주 신고가",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_HIGH",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-280",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 52주 신저가",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_LOW",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-281",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 시간외 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-282",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 시간외 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-283",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 시가총액",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-284",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 예상가 시가총액 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-285",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-286",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-287",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 52주 신고가",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_HIGH",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-288",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 52주 신저가",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_LOW",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-289",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 시간외 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-290",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥 시간외 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-291",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피 200 종목",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "KOSPI200",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-292",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "ETF 종목",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "ETF",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-293",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "VI 발동(수동)",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": false,
+ "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "PAGED_VI",
+ "subject": "",
+ "graphicType": "INPUT_ORDER",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-294",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피_NXT 시가총액",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-295",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피_NXT 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-296",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피_NXT 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-297",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스피_NXT 거래량 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-298",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥_NXT 시가총액",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-299",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥_NXT 상승률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-300",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥_NXT 하락률 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-301",
+ "market": "index",
+ "section": "6종목 현재가",
+ "label": "코스닥_NXT 거래량 상위",
+ "detail": "6종목 현재가",
+ "category": "plate",
+ "builderKey": "s5077",
+ "code": "5077",
+ "aliases": [
+ "5077"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-302",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 시가총액",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-303",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 예상가 시가총액 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-304",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-305",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-306",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 52주 신고가",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_HIGH",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-307",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 52주 신저가",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_LOW",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-308",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 시간외 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-309",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 시간외 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-310",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 시가총액",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-311",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 예상가 시가총액 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "EXPECTED_MARKET_CAP",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-312",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-313",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-314",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 52주 신고가",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_HIGH",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-315",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 52주 신저가",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "52_WEEK_LOW",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-316",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 시간외 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_GAIN_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-317",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥 시간외 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "AFTER_HOURS_LOSS_RATE",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-318",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피 200 종목",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "KOSPI200",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-319",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "ETF 종목",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_DOMESTIC_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "",
+ "subtype": "ETF",
+ "dataCode": "$TODAY"
+ },
+ "dynamicDate": true,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-320",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "VI 발동(수동)",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5074",
+ "code": "5074",
+ "aliases": [
+ "5074"
+ ],
+ "available": false,
+ "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.",
+ "selection": {
+ "groupCode": "PAGED_VI",
+ "subject": "",
+ "graphicType": "INPUT_ORDER",
+ "subtype": "CURRENT",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": false
+ },
+ {
+ "id": "fixed-321",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피_NXT 시가총액",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-322",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피_NXT 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-323",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피_NXT 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-324",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스피_NXT 거래량 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSPI",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-325",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥_NXT 시가총액",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "MARKET_CAP",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-326",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥_NXT 상승률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "GAIN_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-327",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥_NXT 하락률 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "LOSS_RATE",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ },
+ {
+ "id": "fixed-328",
+ "market": "index",
+ "section": "12종목 현재가",
+ "label": "코스닥_NXT 거래량 상위",
+ "detail": "12종목 현재가",
+ "category": "plate",
+ "builderKey": "s5088",
+ "code": "5088",
+ "aliases": [
+ "5088"
+ ],
+ "available": true,
+ "prerequisite": "",
+ "selection": {
+ "groupCode": "PAGED_NXT_KOSDAQ",
+ "subject": "INDEX",
+ "graphicType": "$NXT_SESSION",
+ "subtype": "VOLUME",
+ "dataCode": ""
+ },
+ "dynamicDate": false,
+ "dynamicNxtSession": true
+ }
+ ]
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs
new file mode 100644
index 0000000..ce00c50
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs
@@ -0,0 +1,231 @@
+using System.Globalization;
+using System.Text;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+///
+/// Adapts the already parameterized domestic/world lookup services to the closed UC3
+/// comparison identity model. Database identities never cross the WebView boundary.
+///
+public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
+{
+ private readonly ILegacyStockLookup _domesticLookup;
+ private readonly IWorldStockSearchService _worldLookup;
+
+ public LegacyComparisonDataService(
+ ILegacyStockLookup domesticLookup,
+ IWorldStockSearchService worldLookup)
+ {
+ _domesticLookup = domesticLookup ?? throw new ArgumentNullException(nameof(domesticLookup));
+ _worldLookup = worldLookup ?? throw new ArgumentNullException(nameof(worldLookup));
+ }
+
+ public async Task> SearchDomesticAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.DomesticMaximumResults);
+ var result = await _domesticLookup.SearchAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ var targets = new List(
+ Math.Min(result.DisplayNames.Count, maximumResults));
+ var identities = new HashSet(StringComparer.Ordinal);
+ foreach (var displayName in result.DisplayNames)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var identity = result.ResolveLastByDisplayName(displayName);
+ if (identity is null)
+ {
+ continue;
+ }
+
+ var target = ToComparisonTarget(identity);
+ if (identities.Add(target.TargetId))
+ {
+ targets.Add(target);
+ if (targets.Count == maximumResults)
+ {
+ break;
+ }
+ }
+ }
+
+ return targets.AsReadOnly();
+ }
+
+ public async Task> SearchWorldAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults);
+ var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken)
+ .ConfigureAwait(false);
+ return Array.AsReadOnly(result.Items
+ .Select(item => LegacyComparisonTarget.CreateWorldStock(
+ item.InputName,
+ item.Symbol,
+ item.NationCode))
+ .ToArray());
+ }
+
+ public async Task ResolveStoredTargetAsync(
+ LegacyComparisonStoredTargetReference target,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ if (target.Market.Length == 0)
+ {
+ var world = await SearchWorldAsync(
+ target.Name,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ return world.FirstOrDefault(item =>
+ LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
+ }
+
+ var domestic = await SearchDomesticAsync(
+ RemoveNxtSuffix(target.Name),
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ return domestic.FirstOrDefault(item =>
+ LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
+ }
+
+ private static LegacyComparisonTarget ToComparisonTarget(LegacyStockIdentity identity)
+ {
+ var name = RemoveNxtSuffix(identity.DisplayName);
+ return identity.Market switch
+ {
+ LegacyDomesticStockMarket.Kospi => LegacyComparisonTarget.CreateKrxStock(
+ LegacyComparisonDomesticMarket.Kospi,
+ name,
+ identity.StockCode),
+ LegacyDomesticStockMarket.Kosdaq => LegacyComparisonTarget.CreateKrxStock(
+ LegacyComparisonDomesticMarket.Kosdaq,
+ name,
+ identity.StockCode),
+ LegacyDomesticStockMarket.NxtKospi => LegacyComparisonTarget.CreateNxtStock(
+ LegacyComparisonDomesticMarket.Kospi,
+ name,
+ identity.StockCode),
+ LegacyDomesticStockMarket.NxtKosdaq => LegacyComparisonTarget.CreateNxtStock(
+ LegacyComparisonDomesticMarket.Kosdaq,
+ name,
+ identity.StockCode),
+ _ => throw new InvalidOperationException("The domestic stock market is invalid.")
+ };
+ }
+
+ private static string RemoveNxtSuffix(string value) =>
+ value.EndsWith("(NXT)", StringComparison.Ordinal)
+ ? value[..^5]
+ : value;
+
+ private static void ValidateLimit(int value, int maximum)
+ {
+ if (value is < 1 || value > maximum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+ }
+}
+
+///
+/// Trusted, fixed-path CP949 store for the original 종목비교.dat shape. Reads may fall
+/// back to a read-only seed, but all writes are atomic and confined to the local path.
+///
+public sealed class LegacyComparisonPairFileStore : ILegacyComparisonPairStore
+{
+ private readonly string _path;
+ private readonly string? _readOnlySeedPath;
+
+ public LegacyComparisonPairFileStore(string path, string? readOnlySeedPath = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _path = Path.GetFullPath(path);
+ _readOnlySeedPath = string.IsNullOrWhiteSpace(readOnlySeedPath)
+ ? null
+ : Path.GetFullPath(readOnlySeedPath);
+ }
+
+ public async Task ReadCp949Async(CancellationToken cancellationToken = default)
+ {
+ var source = File.Exists(_path)
+ ? _path
+ : _readOnlySeedPath is not null && File.Exists(_readOnlySeedPath)
+ ? _readOnlySeedPath
+ : null;
+ return source is null
+ ? null
+ : await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task WriteCp949Async(
+ IReadOnlyList rows,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(rows);
+ if (rows.Count > MMoneyCoderSharp.Data.LegacyComparisonFileParser.MaximumRows)
+ {
+ throw new ArgumentOutOfRangeException(nameof(rows));
+ }
+
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ var cp949 = Encoding.GetEncoding(
+ 949,
+ EncoderFallback.ExceptionFallback,
+ DecoderFallback.ExceptionFallback);
+ var text = string.Join(
+ '\n',
+ rows.Select((row, index) => SerializeRow(row, index + 1)));
+ var bytes = cp949.GetBytes(text);
+ if (bytes.Length > 0)
+ {
+ _ = MMoneyCoderSharp.Data.LegacyComparisonFileParser.Parse(bytes);
+ }
+
+ var directory = Path.GetDirectoryName(_path) ??
+ throw new InvalidOperationException("The comparison data path has no directory.");
+ Directory.CreateDirectory(directory);
+ var temporaryPath = Path.Combine(
+ directory,
+ $".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp");
+ try
+ {
+ await File.WriteAllBytesAsync(temporaryPath, bytes, cancellationToken)
+ .ConfigureAwait(false);
+ File.Move(temporaryPath, _path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporaryPath))
+ {
+ File.Delete(temporaryPath);
+ }
+ }
+ }
+
+ private static string SerializeRow(LegacyComparisonPairWriteRow row, int expectedNumber)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ if (row.RowNumber != expectedNumber)
+ {
+ throw new InvalidOperationException("Comparison row numbers must be contiguous.");
+ }
+
+ return string.Join(
+ '^',
+ row.RowNumber.ToString(CultureInfo.InvariantCulture),
+ $"{row.FirstName},{row.SecondName}",
+ row.FirstMarket,
+ row.SecondMarket,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ string.Empty);
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs
new file mode 100644
index 0000000..4505940
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs
@@ -0,0 +1,427 @@
+using System.Collections.ObjectModel;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+internal enum LegacyComparisonActionKind
+{
+ TwoColumn,
+ Comparison,
+ Return
+}
+
+internal sealed record LegacyComparisonActionDefinition(
+ string ActionId,
+ string Label,
+ string Section,
+ LegacyComparisonActionKind Kind,
+ string? Mode = null,
+ string? BuilderKey = null,
+ string? CutCode = null,
+ string? GraphicType = null,
+ string? Period = null);
+
+internal sealed record LegacyComparisonCompatibility(
+ bool IsAvailable,
+ string? Reason,
+ string? EffectiveMode,
+ string? Warning);
+
+internal static class LegacyComparisonWorkflowCatalog
+{
+ public const int DefaultFadeDuration = 6;
+ public const int DomesticMaximumResults = 200;
+ public const int WorldMaximumResults = 100;
+
+ private static readonly ReadOnlyCollection FixedTargetList =
+ Array.AsReadOnly(new[]
+ {
+ Market("kospi", "코스피 지수", "Kospi"),
+ Market("kosdaq", "코스닥 지수", "Kosdaq"),
+ Market("kospi200", "코스피200 지수", "Kospi200"),
+ Market("futures", "선물 지수", "Futures"),
+ Market("krx100", "KRX100지수", "Krx100"),
+ Market("won-dollar", "환율-원달러", "WonDollar"),
+ Market("won-yen", "환율-원엔", "WonYen"),
+ Market("won-yuan", "환율-원위엔", "WonYuan"),
+ Market("won-euro", "환율-원유로", "WonEuro"),
+ Market("dow", "해외지수-다우", "Dow"),
+ Market("nasdaq", "해외지수-나스닥", "Nasdaq"),
+ Market("sp500", "해외지수-S&P", "Sp500"),
+ Market("germany-dax", "해외지수-독일", "GermanyDax"),
+ Market("united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"),
+ Market("france-cac", "해외지수-프랑스", "FranceCac"),
+ Market("nikkei", "해외지수-일본", "Nikkei"),
+ Market("hang-seng", "해외지수-홍콩", "HangSeng"),
+ Market("taiwan-weighted", "해외지수-대만", "TaiwanWeighted"),
+ Market("singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"),
+ Market("thailand-set", "해외지수-태국", "ThailandSet"),
+ Market("philippines-composite", "해외지수-필리핀", "PhilippinesComposite"),
+ Market("malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"),
+ Market("indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"),
+ Market("shanghai-composite", "해외지수-중국", "ShanghaiComposite")
+ });
+
+ private static readonly IReadOnlyDictionary FixedById =
+ FixedTargetList.ToDictionary(static target => target.TargetId, StringComparer.Ordinal);
+
+ private static readonly IReadOnlyDictionary FixedByDisplayName =
+ FixedTargetList.ToDictionary(static target => target.DisplayName, StringComparer.Ordinal);
+
+ private static readonly ReadOnlyCollection ActionList =
+ Array.AsReadOnly(new[]
+ {
+ new LegacyComparisonActionDefinition(
+ "two-column-expected", "2열판 예상체결", "2열판",
+ LegacyComparisonActionKind.TwoColumn, Mode: "EXPECTED"),
+ new LegacyComparisonActionDefinition(
+ "two-column-current", "2열판", "2열판",
+ LegacyComparisonActionKind.TwoColumn, Mode: "CURRENT"),
+ new LegacyComparisonActionDefinition(
+ "comparison-candle", "종목별 비교분석_캔들 그래프", "종목별 비교분석",
+ LegacyComparisonActionKind.Comparison,
+ BuilderKey: "s5026", CutCode: "5026",
+ GraphicType: "COMPARISON_CANDLE", Period: "FiveDays"),
+ new LegacyComparisonActionDefinition(
+ "comparison-line", "종목별 비교분석_라인 그래프", "종목별 비교분석",
+ LegacyComparisonActionKind.Comparison,
+ BuilderKey: "s5087", CutCode: "5087",
+ GraphicType: "COMPARISON_LINE", Period: "FiveDays"),
+ Return("return-5d", "5일", "FiveDays"),
+ Return("return-1m", "1개월", "OneMonth"),
+ Return("return-3m", "3개월", "ThreeMonths"),
+ Return("return-6m", "6개월", "SixMonths"),
+ Return("return-12m", "12개월", "TwelveMonths")
+ });
+
+ private static readonly IReadOnlyDictionary ActionsById =
+ ActionList.ToDictionary(static action => action.ActionId, StringComparer.Ordinal);
+
+ private static readonly HashSet ExpectedMarketTargets =
+ new(StringComparer.Ordinal) { "Kospi", "Kosdaq", "Kospi200", "Krx100" };
+
+ static LegacyComparisonWorkflowCatalog()
+ {
+ if (FixedTargetList.Count != 24 || FixedById.Count != 24 ||
+ ActionList.Count != 9 || ActionsById.Count != 9)
+ {
+ throw new InvalidOperationException("The closed UC3 comparison catalog is invalid.");
+ }
+ }
+
+ public static IReadOnlyList FixedTargets => FixedTargetList;
+
+ public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
+ targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
+
+ public static LegacyComparisonTarget RequireCanonicalTarget(LegacyComparisonTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ if (target.Kind != LegacyComparisonTargetKind.MarketTarget)
+ {
+ return target;
+ }
+
+ if (!FixedById.TryGetValue(target.TargetId, out var canonical) || canonical != target)
+ {
+ throw new InvalidOperationException("The data service returned an unknown market target.");
+ }
+
+ return canonical;
+ }
+
+ public static LegacyComparisonTargetView ToView(
+ LegacyComparisonTarget target,
+ string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ LegacyComparisonValueGuard.RequireIdentifier(selectionId, 128, nameof(selectionId));
+ var metadata = target.Kind switch
+ {
+ LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
+ LegacyComparisonTargetKind.WorldStock => $"{target.Nation} · {target.Symbol}",
+ LegacyComparisonTargetKind.KrxStock =>
+ $"{MarketText(target.DomesticMarket)} KRX · {target.StockCode}",
+ LegacyComparisonTargetKind.NxtStock =>
+ $"{MarketText(target.DomesticMarket)} NXT · {target.StockCode}",
+ _ => throw new InvalidOperationException("The comparison target kind is invalid.")
+ };
+ return new LegacyComparisonTargetView(
+ selectionId,
+ target.DisplayName,
+ metadata,
+ target.Kind);
+ }
+
+ public static LegacyComparisonStoredTargetReference ToStoredReference(
+ LegacyComparisonTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ return target.Kind switch
+ {
+ LegacyComparisonTargetKind.MarketTarget =>
+ new LegacyComparisonStoredTargetReference(target.DisplayName, string.Empty),
+ LegacyComparisonTargetKind.KrxStock =>
+ new LegacyComparisonStoredTargetReference(
+ target.StockName!,
+ target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
+ ? "코스피"
+ : "코스닥"),
+ LegacyComparisonTargetKind.NxtStock =>
+ new LegacyComparisonStoredTargetReference(
+ target.DisplayName,
+ target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
+ ? "코스피_NXT"
+ : "코스닥_NXT"),
+ LegacyComparisonTargetKind.WorldStock =>
+ new LegacyComparisonStoredTargetReference(target.InputName!, string.Empty),
+ _ => throw new InvalidOperationException("The comparison target kind is invalid.")
+ };
+ }
+
+ public static bool MatchesStoredReference(
+ LegacyComparisonTarget target,
+ LegacyComparisonStoredTargetReference stored) =>
+ ToStoredReference(target) == stored;
+
+ public static LegacyComparisonTarget? TryResolveFixedStoredTarget(
+ LegacyComparisonStoredTargetReference stored) =>
+ stored.Market.Length == 0 &&
+ FixedByDisplayName.TryGetValue(stored.Name, out var target)
+ ? target
+ : null;
+
+ public static IReadOnlyList CreateActionViews(
+ LegacyComparisonTarget? first,
+ LegacyComparisonTarget? second)
+ {
+ var views = ActionList.Select(action =>
+ {
+ var compatibility = GetCompatibility(action, first, second);
+ return new LegacyComparisonActionView(
+ action.ActionId,
+ action.Label,
+ action.Section,
+ compatibility.IsAvailable,
+ compatibility.Reason);
+ }).ToArray();
+ return Array.AsReadOnly(views);
+ }
+
+ public static LegacyGenericPlaylistDraft Materialize(
+ string actionId,
+ LegacyComparisonTarget first,
+ LegacyComparisonTarget second,
+ string draftId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!ActionsById.TryGetValue(actionId, out var action))
+ {
+ throw new ArgumentOutOfRangeException(nameof(actionId), "The comparison action is unknown.");
+ }
+
+ var compatibility = GetCompatibility(action, first, second);
+ if (!compatibility.IsAvailable)
+ {
+ throw new InvalidOperationException(
+ $"The selected pair cannot use this comparison action: {compatibility.Reason}.");
+ }
+
+ LegacyComparisonValueGuard.RequireIdentifier(draftId, 128, nameof(draftId));
+ var subject = $"{SubjectToken(first)},{SubjectToken(second)}";
+ string builderKey;
+ string cutCode;
+ string graphicType;
+ string subtype;
+ string dataCode;
+ string category;
+ if (action.Kind == LegacyComparisonActionKind.TwoColumn)
+ {
+ var hasFutures = IsFutures(first) || IsFutures(second);
+ builderKey = hasFutures ? "s5032" : "s8018";
+ cutCode = hasFutures ? "5032" : "8032";
+ graphicType = "2열판";
+ subtype = action.Mode!;
+ dataCode = string.Empty;
+ category = "plate";
+ }
+ else
+ {
+ builderKey = action.Kind == LegacyComparisonActionKind.Return
+ ? "s5029"
+ : action.BuilderKey!;
+ cutCode = action.Kind == LegacyComparisonActionKind.Return
+ ? "5029"
+ : action.CutCode!;
+ graphicType = action.Kind == LegacyComparisonActionKind.Return
+ ? "RETURN_COMPARISON"
+ : action.GraphicType!;
+ subtype = action.Period!;
+ dataCode = ExactKrxIdentityEnvelope(first, second);
+ category = "chart";
+ }
+
+ var selection = new LegacySceneSelection(
+ first.Kind == LegacyComparisonTargetKind.MarketTarget ||
+ second.Kind == LegacyComparisonTargetKind.MarketTarget
+ ? "INDEX"
+ : "STOCK",
+ subject,
+ graphicType,
+ subtype,
+ dataCode);
+ return new LegacyGenericPlaylistDraft(
+ draftId,
+ builderKey,
+ cutCode,
+ Array.AsReadOnly(new[] { cutCode }),
+ $"{first.DisplayName} ↔ {second.DisplayName}",
+ action.Label,
+ category,
+ "compare",
+ selection,
+ true,
+ DefaultFadeDuration,
+ "legacy-comparison-workflow",
+ action.ActionId,
+ compatibility.EffectiveMode,
+ compatibility.Warning);
+ }
+
+ public static string PairSignature(
+ LegacyComparisonTarget first,
+ LegacyComparisonTarget second)
+ {
+ var firstStored = ToStoredReference(first);
+ var secondStored = ToStoredReference(second);
+ return string.Join(
+ '\u001f',
+ firstStored.Name,
+ firstStored.Market,
+ secondStored.Name,
+ secondStored.Market);
+ }
+
+ private static LegacyComparisonCompatibility GetCompatibility(
+ LegacyComparisonActionDefinition action,
+ LegacyComparisonTarget? first,
+ LegacyComparisonTarget? second)
+ {
+ if (first is null || second is null)
+ {
+ return Unavailable("pair-incomplete-or-invalid");
+ }
+
+ if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return)
+ {
+ return first.Kind == LegacyComparisonTargetKind.KrxStock &&
+ second.Kind == LegacyComparisonTargetKind.KrxStock
+ ? Available("GRAPH")
+ : Unavailable("comparison-graphs-require-two-krx-stocks");
+ }
+
+ var futuresCount = (IsFutures(first) ? 1 : 0) + (IsFutures(second) ? 1 : 0);
+ if (futuresCount > 1)
+ {
+ return Unavailable("two-futures-targets-are-unsupported");
+ }
+
+ if (futuresCount == 1)
+ {
+ var counterpart = IsFutures(first) ? second : first;
+ if (counterpart.Kind != LegacyComparisonTargetKind.MarketTarget)
+ {
+ return Unavailable("futures-requires-a-market-target-counterpart");
+ }
+
+ return action.Mode == "EXPECTED"
+ ? Available(
+ "CURRENT",
+ "The current Core routes a futures expected action through the current s5032 branch.")
+ : Available("CURRENT");
+ }
+
+ if (action.Mode == "EXPECTED")
+ {
+ var supported = IsExpectedTarget(first) && IsExpectedTarget(second);
+ return supported
+ ? Available("EXPECTED")
+ : Unavailable(
+ "expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
+ }
+
+ var hasMarket = first.Kind == LegacyComparisonTargetKind.MarketTarget ||
+ second.Kind == LegacyComparisonTargetKind.MarketTarget;
+ var hasWorld = first.Kind == LegacyComparisonTargetKind.WorldStock ||
+ second.Kind == LegacyComparisonTargetKind.WorldStock;
+ return hasMarket && hasWorld
+ ? Unavailable("mixed-market-and-world-is-unsupported")
+ : Available("CURRENT");
+ }
+
+ private static bool IsExpectedTarget(LegacyComparisonTarget target) =>
+ target.Kind == LegacyComparisonTargetKind.KrxStock ||
+ target.Kind == LegacyComparisonTargetKind.MarketTarget &&
+ ExpectedMarketTargets.Contains(target.MarketTarget!);
+
+ private static bool IsFutures(LegacyComparisonTarget target) =>
+ target.Kind == LegacyComparisonTargetKind.MarketTarget &&
+ string.Equals(target.MarketTarget, "Futures", StringComparison.Ordinal);
+
+ private static string SubjectToken(LegacyComparisonTarget target) => target.Kind switch
+ {
+ LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
+ LegacyComparisonTargetKind.KrxStock => target.StockName!,
+ LegacyComparisonTargetKind.NxtStock => target.DisplayName,
+ LegacyComparisonTargetKind.WorldStock => target.InputName!,
+ _ => throw new InvalidOperationException("The comparison target kind is invalid.")
+ };
+
+ private static string ExactKrxIdentityEnvelope(
+ LegacyComparisonTarget first,
+ LegacyComparisonTarget second)
+ {
+ if (first.Kind != LegacyComparisonTargetKind.KrxStock ||
+ second.Kind != LegacyComparisonTargetKind.KrxStock)
+ {
+ throw new InvalidOperationException("Comparison graphs require two KRX stocks.");
+ }
+
+ return $"{MarketIdentity(first)}:{first.StockCode}|{MarketIdentity(second)}:{second.StockCode}";
+ }
+
+ private static string MarketIdentity(LegacyComparisonTarget target) =>
+ target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi ? "KOSPI" : "KOSDAQ";
+
+ private static string MarketText(LegacyComparisonDomesticMarket? market) => market switch
+ {
+ LegacyComparisonDomesticMarket.Kospi => "KOSPI",
+ LegacyComparisonDomesticMarket.Kosdaq => "KOSDAQ",
+ _ => throw new InvalidOperationException("The domestic comparison market is missing.")
+ };
+
+ private static LegacyComparisonCompatibility Available(
+ string effectiveMode,
+ string? warning = null) =>
+ new(true, null, effectiveMode, warning);
+
+ private static LegacyComparisonCompatibility Unavailable(string reason) =>
+ new(false, reason, null, null);
+
+ private static LegacyComparisonTarget Market(
+ string id,
+ string displayName,
+ string target) =>
+ LegacyComparisonTarget.CreateMarketTarget(id, displayName, target);
+
+ private static LegacyComparisonActionDefinition Return(
+ string id,
+ string label,
+ string period) =>
+ new(
+ id,
+ label,
+ "종목별 수익률 비교",
+ LegacyComparisonActionKind.Return,
+ Period: period);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
new file mode 100644
index 0000000..34497e4
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
@@ -0,0 +1,664 @@
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+///
+/// C# authority for the original UC3 comparison workflow. Search results and fixed targets
+/// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and
+/// an action id is the only caller-supplied value used to materialize a playlist draft.
+///
+public sealed class LegacyComparisonWorkflowController
+{
+ private readonly ILegacyComparisonDataService _dataService;
+ private readonly ILegacyComparisonPairStore _pairStore;
+ private List _domesticResults = [];
+ private List _worldResults = [];
+ private List _savedPairs = [];
+ private LegacyComparisonTarget? _firstTarget;
+ private LegacyComparisonTarget? _secondTarget;
+ private string? _selectedDomesticResultId;
+ private string? _selectedWorldResultId;
+ private string? _selectedFixedTargetId;
+ private string? _selectedPairId;
+ private string _domesticQuery = string.Empty;
+ private string _worldQuery = string.Empty;
+ private string _domesticError = string.Empty;
+ private string _worldError = string.Empty;
+ private string _statusMessage = string.Empty;
+ private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle;
+ private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle;
+ private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
+ private long _revision;
+ private long _pairSequence;
+ private long _draftSequence;
+ private int _domesticSearchVersion;
+ private int _worldSearchVersion;
+ private bool _isBusy;
+
+ public LegacyComparisonWorkflowController(
+ ILegacyComparisonDataService dataService,
+ ILegacyComparisonPairStore pairStore)
+ {
+ _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
+ _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
+ }
+
+ public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
+
+ public async Task SearchDomesticAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedQuery = NormalizeSearchQuery(query);
+ var version = ++_domesticSearchVersion;
+ _domesticQuery = normalizedQuery;
+ _domesticStatus = LegacyComparisonSearchStatus.Loading;
+ _domesticError = string.Empty;
+ _domesticResults = [];
+ _selectedDomesticResultId = null;
+ Touch();
+
+ try
+ {
+ var results = await _dataService.SearchDomesticAsync(
+ normalizedQuery,
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ if (version != _domesticSearchVersion)
+ {
+ return CreateSnapshot();
+ }
+
+ _domesticResults = ValidateSearchResults(
+ results,
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
+ domestic: true,
+ version);
+ _domesticStatus = LegacyComparisonSearchStatus.Ready;
+ _domesticError = string.Empty;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (OperationCanceledException)
+ {
+ if (version == _domesticSearchVersion)
+ {
+ _domesticStatus = LegacyComparisonSearchStatus.Idle;
+ _domesticError = string.Empty;
+ Touch();
+ }
+
+ throw;
+ }
+ catch
+ {
+ if (version == _domesticSearchVersion)
+ {
+ _domesticResults = [];
+ _domesticStatus = LegacyComparisonSearchStatus.Error;
+ _domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다.";
+ Touch();
+ }
+
+ throw;
+ }
+ }
+
+ public async Task SearchWorldAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ // UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
+ var normalizedQuery = NormalizeSearchQuery(query);
+ var version = ++_worldSearchVersion;
+ _worldQuery = normalizedQuery;
+ _worldStatus = LegacyComparisonSearchStatus.Loading;
+ _worldError = string.Empty;
+ _worldResults = [];
+ _selectedWorldResultId = null;
+ Touch();
+
+ try
+ {
+ var results = await _dataService.SearchWorldAsync(
+ normalizedQuery,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ if (version != _worldSearchVersion)
+ {
+ return CreateSnapshot();
+ }
+
+ _worldResults = ValidateSearchResults(
+ results,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults,
+ domestic: false,
+ version);
+ _worldStatus = LegacyComparisonSearchStatus.Ready;
+ _worldError = string.Empty;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (OperationCanceledException)
+ {
+ if (version == _worldSearchVersion)
+ {
+ _worldStatus = LegacyComparisonSearchStatus.Idle;
+ _worldError = string.Empty;
+ Touch();
+ }
+
+ throw;
+ }
+ catch
+ {
+ if (version == _worldSearchVersion)
+ {
+ _worldResults = [];
+ _worldStatus = LegacyComparisonSearchStatus.Error;
+ _worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다.";
+ Touch();
+ }
+
+ throw;
+ }
+ }
+
+ public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) =>
+ SelectSearchResult(_domesticResults, selectionId, domestic: true);
+
+ public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) =>
+ SelectSearchResult(_worldResults, selectionId, domestic: false);
+
+ public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId)
+ {
+ ArgumentNullException.ThrowIfNull(targetId);
+ if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null)
+ {
+ _selectedFixedTargetId = targetId;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) =>
+ ChooseSearchResult(_domesticResults, selectionId, domestic: true);
+
+ public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) =>
+ ChooseSearchResult(_worldResults, selectionId, domestic: false);
+
+ public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId)
+ {
+ ArgumentNullException.ThrowIfNull(targetId);
+ var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId);
+ if (target is not null)
+ {
+ _selectedFixedTargetId = targetId;
+ RotateDraft(target);
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public LegacyComparisonWorkflowSnapshot SwapDraftTargets()
+ {
+ (_firstTarget, _secondTarget) = (_secondTarget, _firstTarget);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyComparisonWorkflowSnapshot ClearDraftTargets()
+ {
+ _firstTarget = null;
+ _secondTarget = null;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task LoadSavedPairsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ BeginBusy();
+ try
+ {
+ var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false);
+ var candidate = new List();
+ if (source is { Length: > 0 })
+ {
+ var bytes = source.ToArray();
+ var rows = LegacyComparisonFileParser.Parse(bytes);
+ foreach (var row in rows)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var firstReference = new LegacyComparisonStoredTargetReference(
+ row.First.Name,
+ row.First.Market);
+ var secondReference = new LegacyComparisonStoredTargetReference(
+ row.Second.Name,
+ row.Second.Market);
+ var first = await ResolveStoredTargetAsync(
+ firstReference,
+ cancellationToken).ConfigureAwait(false);
+ var second = await ResolveStoredTargetAsync(
+ secondReference,
+ cancellationToken).ConfigureAwait(false);
+ candidate.Add(new SavedPairState(
+ $"comparison-pair-{row.RowNumber:D8}",
+ first,
+ second));
+ }
+ }
+
+ _savedPairs = candidate;
+ _pairSequence = Math.Max(_pairSequence, candidate.Count);
+ _selectedPairId = candidate.FirstOrDefault()?.RowId;
+ _statusMessage = candidate.Count == 0
+ ? "저장된 종목 비교쌍이 없습니다."
+ : $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch
+ {
+ _statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Error;
+ throw;
+ }
+ finally
+ {
+ EndBusy();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public async Task AddDraftPairAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_firstTarget is null || _secondTarget is null)
+ {
+ throw new InvalidOperationException("Select two comparison targets first.");
+ }
+
+ if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows)
+ {
+ throw new InvalidOperationException("The saved comparison pair limit was reached.");
+ }
+
+ var signature = LegacyComparisonWorkflowCatalog.PairSignature(
+ _firstTarget,
+ _secondTarget);
+ if (_savedPairs.Any(pair => string.Equals(
+ LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second),
+ signature,
+ StringComparison.Ordinal)))
+ {
+ throw new InvalidOperationException("The exact comparison pair is already saved.");
+ }
+
+ var rowId = $"comparison-pair-{++_pairSequence:D8}";
+ var candidate = new List(_savedPairs)
+ {
+ new(rowId, _firstTarget, _secondTarget)
+ };
+ return await CommitPairsAsync(
+ candidate,
+ rowId,
+ "종목 비교쌍을 저장했습니다.",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId)
+ {
+ ArgumentNullException.ThrowIfNull(rowId);
+ if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal)))
+ {
+ _selectedPairId = rowId;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public async Task MoveSelectedPairAsync(
+ LegacyComparisonPairMoveDirection direction,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.IsDefined(direction))
+ {
+ throw new ArgumentOutOfRangeException(nameof(direction));
+ }
+
+ var index = SelectedPairIndex();
+ var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1);
+ if (index < 0 || destination < 0 || destination >= _savedPairs.Count)
+ {
+ return CreateSnapshot();
+ }
+
+ var candidate = new List(_savedPairs);
+ (candidate[index], candidate[destination]) = (candidate[destination], candidate[index]);
+ return await CommitPairsAsync(
+ candidate,
+ _selectedPairId,
+ "종목 비교쌍 순서를 변경했습니다.",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task DeleteSelectedPairAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var index = SelectedPairIndex();
+ if (index < 0)
+ {
+ return CreateSnapshot();
+ }
+
+ var candidate = new List(_savedPairs);
+ candidate.RemoveAt(index);
+ var nextSelection = candidate.Count == 0
+ ? null
+ : candidate[Math.Min(index, candidate.Count - 1)].RowId;
+ return await CommitPairsAsync(
+ candidate,
+ nextSelection,
+ "선택한 종목 비교쌍을 삭제했습니다.",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task DeleteAllPairsAsync(
+ CancellationToken cancellationToken = default) =>
+ _savedPairs.Count == 0
+ ? Task.FromResult(CreateSnapshot())
+ : CommitPairsAsync(
+ [],
+ null,
+ "종목 비교쌍을 모두 삭제했습니다.",
+ cancellationToken);
+
+ public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (_isBusy)
+ {
+ throw new InvalidOperationException("The comparison workflow is busy.");
+ }
+
+ var index = SelectedPairIndex();
+ if (index < 0)
+ {
+ throw new InvalidOperationException("Select a saved comparison pair first.");
+ }
+
+ var pair = _savedPairs[index];
+ var draftId = $"comparison-draft-{++_draftSequence:D8}";
+ return LegacyComparisonWorkflowCatalog.Materialize(
+ actionId,
+ pair.First,
+ pair.Second,
+ draftId);
+ }
+
+ private LegacyComparisonWorkflowSnapshot ChooseSearchResult(
+ IReadOnlyList results,
+ string selectionId,
+ bool domestic)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ var result = results.FirstOrDefault(item =>
+ string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal));
+ if (result is not null)
+ {
+ if (domestic)
+ {
+ _selectedDomesticResultId = selectionId;
+ }
+ else
+ {
+ _selectedWorldResultId = selectionId;
+ }
+
+ RotateDraft(result.Target);
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ private LegacyComparisonWorkflowSnapshot SelectSearchResult(
+ IReadOnlyList results,
+ string selectionId,
+ bool domestic)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (results.Any(item => string.Equals(
+ item.SelectionId,
+ selectionId,
+ StringComparison.Ordinal)))
+ {
+ if (domestic)
+ {
+ _selectedDomesticResultId = selectionId;
+ }
+ else
+ {
+ _selectedWorldResultId = selectionId;
+ }
+
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ private void RotateDraft(LegacyComparisonTarget selected)
+ {
+ _secondTarget = _firstTarget;
+ _firstTarget = selected;
+ }
+
+ private async Task ResolveStoredTargetAsync(
+ LegacyComparisonStoredTargetReference stored,
+ CancellationToken cancellationToken)
+ {
+ var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored);
+ var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync(
+ stored,
+ cancellationToken).ConfigureAwait(false);
+ if (resolved is null)
+ {
+ throw new InvalidOperationException("A stored comparison target no longer resolves.");
+ }
+
+ resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved);
+ if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored))
+ {
+ throw new InvalidOperationException("A stored comparison target resolved to a different identity.");
+ }
+
+ return resolved;
+ }
+
+ private async Task CommitPairsAsync(
+ IReadOnlyList candidate,
+ string? nextSelection,
+ string successMessage,
+ CancellationToken cancellationToken)
+ {
+ BeginBusy();
+ try
+ {
+ var writeRows = candidate.Select((pair, index) =>
+ {
+ var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
+ var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
+ return new LegacyComparisonPairWriteRow(
+ index + 1,
+ first.Name,
+ first.Market,
+ second.Name,
+ second.Market);
+ }).ToArray();
+ await _pairStore.WriteCp949Async(
+ Array.AsReadOnly(writeRows),
+ cancellationToken).ConfigureAwait(false);
+
+ _savedPairs = candidate.ToList();
+ _selectedPairId = nextSelection;
+ _statusMessage = successMessage;
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch
+ {
+ _statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다.";
+ _statusKind = LegacyOperatorStatusKind.Error;
+ throw;
+ }
+ finally
+ {
+ EndBusy();
+ }
+
+ return CreateSnapshot();
+ }
+
+ private static List ValidateSearchResults(
+ IReadOnlyList? results,
+ int maximumResults,
+ bool domestic,
+ int version)
+ {
+ if (results is null || results.Count > maximumResults)
+ {
+ throw new InvalidOperationException("The comparison search result count is invalid.");
+ }
+
+ var validated = new List(results.Count);
+ var targetIds = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < results.Count; index++)
+ {
+ var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]);
+ var allowedKind = domestic
+ ? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock
+ : target.Kind == LegacyComparisonTargetKind.WorldStock;
+ if (!allowedKind || !targetIds.Add(target.TargetId))
+ {
+ throw new InvalidOperationException("The comparison search result identity is invalid.");
+ }
+
+ validated.Add(new SearchResultState(
+ $"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}",
+ target));
+ }
+
+ return validated;
+ }
+
+ private LegacyComparisonWorkflowSnapshot CreateSnapshot()
+ {
+ var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0
+ ? _savedPairs[selectedIndex]
+ : null;
+ var domesticResults = _domesticResults
+ .Select(result => LegacyComparisonWorkflowCatalog.ToView(
+ result.Target,
+ result.SelectionId))
+ .ToArray();
+ var worldResults = _worldResults
+ .Select(result => LegacyComparisonWorkflowCatalog.ToView(
+ result.Target,
+ result.SelectionId))
+ .ToArray();
+ var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
+ .Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
+ .ToArray();
+ var savedPairs = _savedPairs.Select((pair, index) =>
+ new LegacyComparisonSavedPairView(
+ pair.RowId,
+ index + 1,
+ $"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}",
+ LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"),
+ LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"),
+ string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)))
+ .ToArray();
+ return new LegacyComparisonWorkflowSnapshot(
+ _revision,
+ _isBusy,
+ new LegacyComparisonSearchSnapshot(
+ _domesticQuery,
+ _domesticStatus,
+ Array.AsReadOnly(domesticResults),
+ _selectedDomesticResultId,
+ domesticResults.Length,
+ _domesticError),
+ new LegacyComparisonSearchSnapshot(
+ _worldQuery,
+ _worldStatus,
+ Array.AsReadOnly(worldResults),
+ _selectedWorldResultId,
+ worldResults.Length,
+ _worldError),
+ Array.AsReadOnly(fixedTargets),
+ _selectedFixedTargetId,
+ _firstTarget is null
+ ? null
+ : LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"),
+ _secondTarget is null
+ ? null
+ : LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
+ Array.AsReadOnly(savedPairs),
+ _selectedPairId,
+ LegacyComparisonWorkflowCatalog.CreateActionViews(
+ selectedPair?.First,
+ selectedPair?.Second),
+ _statusMessage,
+ _statusKind);
+ }
+
+ private int SelectedPairIndex() => _selectedPairId is null
+ ? -1
+ : _savedPairs.FindIndex(pair =>
+ string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal));
+
+ private static string NormalizeSearchQuery(string query)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ var normalized = query.Trim();
+ if (normalized.Length > 64 ||
+ normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character)))
+ {
+ throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query));
+ }
+
+ return normalized;
+ }
+
+ private void BeginBusy()
+ {
+ if (_isBusy)
+ {
+ throw new InvalidOperationException("The comparison workflow is already busy.");
+ }
+
+ _isBusy = true;
+ Touch();
+ }
+
+ private void EndBusy()
+ {
+ _isBusy = false;
+ Touch();
+ }
+
+ private void Touch() => _revision++;
+
+ private sealed record SearchResultState(
+ string SelectionId,
+ LegacyComparisonTarget Target);
+
+ private sealed record SavedPairState(
+ string RowId,
+ LegacyComparisonTarget First,
+ LegacyComparisonTarget Second);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs
new file mode 100644
index 0000000..e5effb2
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs
@@ -0,0 +1,345 @@
+using System.Globalization;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyComparisonTargetKind
+{
+ MarketTarget,
+ KrxStock,
+ NxtStock,
+ WorldStock
+}
+
+public enum LegacyComparisonDomesticMarket
+{
+ Kospi,
+ Kosdaq
+}
+
+public enum LegacyComparisonSearchStatus
+{
+ Idle,
+ Loading,
+ Ready,
+ Error
+}
+
+public enum LegacyComparisonPairMoveDirection
+{
+ Up,
+ Down
+}
+
+///
+/// Validated native comparison identity. Web code never constructs this type; adapters
+/// create it from trusted DB results and the controller only exposes a display projection.
+///
+public sealed record LegacyComparisonTarget
+{
+ private LegacyComparisonTarget(
+ LegacyComparisonTargetKind kind,
+ string targetId,
+ string displayName,
+ string? marketTarget = null,
+ LegacyComparisonDomesticMarket? domesticMarket = null,
+ string? stockName = null,
+ string? stockCode = null,
+ string? inputName = null,
+ string? symbol = null,
+ string? nation = null)
+ {
+ Kind = kind;
+ TargetId = targetId;
+ DisplayName = displayName;
+ MarketTarget = marketTarget;
+ DomesticMarket = domesticMarket;
+ StockName = stockName;
+ StockCode = stockCode;
+ InputName = inputName;
+ Symbol = symbol;
+ Nation = nation;
+ }
+
+ public LegacyComparisonTargetKind Kind { get; }
+
+ public string TargetId { get; }
+
+ public string DisplayName { get; }
+
+ public string? MarketTarget { get; }
+
+ public LegacyComparisonDomesticMarket? DomesticMarket { get; }
+
+ public string? StockName { get; }
+
+ public string? StockCode { get; }
+
+ public string? InputName { get; }
+
+ public string? Symbol { get; }
+
+ public string? Nation { get; }
+
+ public static LegacyComparisonTarget CreateMarketTarget(
+ string targetId,
+ string displayName,
+ string marketTarget)
+ {
+ LegacyComparisonValueGuard.RequireIdentifier(targetId, 64, nameof(targetId));
+ LegacyComparisonValueGuard.RequireText(displayName, 200, nameof(displayName));
+ LegacyComparisonValueGuard.RequireIdentifier(marketTarget, 64, nameof(marketTarget));
+ return new LegacyComparisonTarget(
+ LegacyComparisonTargetKind.MarketTarget,
+ targetId,
+ displayName,
+ marketTarget: marketTarget);
+ }
+
+ public static LegacyComparisonTarget CreateKrxStock(
+ LegacyComparisonDomesticMarket market,
+ string stockName,
+ string stockCode)
+ {
+ ValidateDomesticStock(stockName, stockCode);
+ var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
+ return new LegacyComparisonTarget(
+ LegacyComparisonTargetKind.KrxStock,
+ $"krx:{marketToken}:{stockCode}",
+ stockName,
+ domesticMarket: market,
+ stockName: stockName,
+ stockCode: stockCode);
+ }
+
+ public static LegacyComparisonTarget CreateNxtStock(
+ LegacyComparisonDomesticMarket market,
+ string stockName,
+ string stockCode)
+ {
+ ValidateDomesticStock(stockName, stockCode);
+ var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
+ return new LegacyComparisonTarget(
+ LegacyComparisonTargetKind.NxtStock,
+ $"nxt:{marketToken}:{stockCode}",
+ $"{stockName}(NXT)",
+ domesticMarket: market,
+ stockName: stockName,
+ stockCode: stockCode);
+ }
+
+ public static LegacyComparisonTarget CreateWorldStock(
+ string inputName,
+ string symbol,
+ string nation)
+ {
+ LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName));
+ if (inputName.Contains(',', StringComparison.Ordinal))
+ {
+ throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName));
+ }
+
+ LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol));
+ var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation));
+ return new LegacyComparisonTarget(
+ LegacyComparisonTargetKind.WorldStock,
+ $"world:{normalizedNation}:{symbol}",
+ inputName,
+ inputName: inputName,
+ symbol: symbol,
+ nation: normalizedNation);
+ }
+
+ private static void ValidateDomesticStock(string stockName, string stockCode)
+ {
+ LegacyComparisonValueGuard.RequireText(stockName, 200, nameof(stockName));
+ if (stockName.Contains(',', StringComparison.Ordinal) ||
+ stockName.Contains("(NXT)", StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "A domestic stock name cannot contain a comma or the NXT display suffix.",
+ nameof(stockName));
+ }
+
+ LegacyComparisonValueGuard.RequireStockCode(stockCode, nameof(stockCode));
+ }
+}
+
+public sealed record LegacyComparisonStoredTargetReference(
+ string Name,
+ string Market);
+
+public interface ILegacyComparisonDataService
+{
+ Task> SearchDomesticAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default);
+
+ Task> SearchWorldAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default);
+
+ Task ResolveStoredTargetAsync(
+ LegacyComparisonStoredTargetReference target,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949
+/// encoding; no UI request can provide a path or raw serialized row.
+///
+public interface ILegacyComparisonPairStore
+{
+ Task ReadCp949Async(CancellationToken cancellationToken = default);
+
+ Task WriteCp949Async(
+ IReadOnlyList rows,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed record LegacyComparisonPairWriteRow(
+ int RowNumber,
+ string FirstName,
+ string FirstMarket,
+ string SecondName,
+ string SecondMarket);
+
+public sealed record LegacyComparisonTargetView(
+ string SelectionId,
+ string DisplayName,
+ string Metadata,
+ LegacyComparisonTargetKind Kind);
+
+public sealed record LegacyComparisonSearchSnapshot(
+ string Query,
+ LegacyComparisonSearchStatus Status,
+ IReadOnlyList Results,
+ string? SelectedResultId,
+ int TotalRowCount,
+ string ErrorMessage);
+
+public sealed record LegacyComparisonSavedPairView(
+ string RowId,
+ int RowNumber,
+ string DisplayLabel,
+ LegacyComparisonTargetView First,
+ LegacyComparisonTargetView Second,
+ bool IsSelected);
+
+public sealed record LegacyComparisonActionView(
+ string ActionId,
+ string Label,
+ string Section,
+ bool IsAvailable,
+ string? UnavailableReason);
+
+public sealed record LegacyComparisonWorkflowSnapshot(
+ long Revision,
+ bool IsBusy,
+ LegacyComparisonSearchSnapshot DomesticSearch,
+ LegacyComparisonSearchSnapshot WorldSearch,
+ IReadOnlyList FixedTargets,
+ string? SelectedFixedTargetId,
+ LegacyComparisonTargetView? FirstTarget,
+ LegacyComparisonTargetView? SecondTarget,
+ IReadOnlyList SavedPairs,
+ string? SelectedPairId,
+ IReadOnlyList Actions,
+ string StatusMessage,
+ LegacyOperatorStatusKind StatusKind);
+
+///
+/// Generic, already validated playlist materialization. Only an action id crosses the UI
+/// command boundary; builder, alias, scene selection and hidden identities are produced here.
+///
+public sealed record LegacyGenericPlaylistDraft(
+ string DraftId,
+ string BuilderKey,
+ string CutCode,
+ IReadOnlyList Aliases,
+ string Title,
+ string Detail,
+ string Category,
+ string Market,
+ LegacySceneSelection Selection,
+ bool IsEnabled,
+ int FadeDuration,
+ string Source,
+ string ActionId,
+ string? EffectiveMode,
+ string? Warning)
+{
+ public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
+ new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
+}
+
+internal static class LegacyComparisonValueGuard
+{
+ public static void RequireText(string value, int maximumLength, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length is <= 0 || value.Length > maximumLength ||
+ !string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
+ value.Contains('^') ||
+ value.Any(static character =>
+ {
+ var category = CharUnicodeInfo.GetUnicodeCategory(character);
+ return char.IsControl(character) || char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ category is UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator;
+ }))
+ {
+ throw new ArgumentException("A canonical comparison text value is required.", parameterName);
+ }
+ }
+
+ public static void RequireIdentifier(string value, int maximumLength, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length is <= 0 || value.Length > maximumLength ||
+ value.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
+ {
+ throw new ArgumentException("A safe comparison identifier is required.", parameterName);
+ }
+ }
+
+ public static void RequireStockCode(string value, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length is <= 0 or > 32 ||
+ value.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-' and not '_'))
+ {
+ throw new ArgumentException("A safe stock code is required.", parameterName);
+ }
+ }
+
+ public static void RequireSymbol(string value, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length is <= 0 or > 64 ||
+ value.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character) &&
+ character is not '@' and not '.' and not '_' and not ':' and not '-'))
+ {
+ throw new ArgumentException("A safe world symbol is required.", parameterName);
+ }
+ }
+
+ public static string RequireNation(string value, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ var normalized = value.ToUpperInvariant();
+ if (normalized is not ("US" or "TW"))
+ {
+ throw new ArgumentException("Only the original US and TW universe is supported.", parameterName);
+ }
+
+ return normalized;
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs
new file mode 100644
index 0000000..4f16669
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs
@@ -0,0 +1,558 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyExpertRequestStatus
+{
+ Idle,
+ Loading,
+ Ready,
+ Error
+}
+
+public sealed record LegacyExpertResultView(
+ string SelectionId,
+ string ExpertCode,
+ string ExpertName);
+
+public sealed record LegacyExpertRecommendationView(
+ int PlayIndex,
+ string StockCode,
+ string StockName,
+ decimal BuyAmount);
+
+public sealed record LegacyExpertSearchSnapshot(
+ string Query,
+ LegacyExpertRequestStatus Status,
+ IReadOnlyList Results,
+ bool IsTruncated,
+ string ErrorMessage);
+
+public sealed record LegacyExpertPreviewSnapshot(
+ LegacyExpertRequestStatus Status,
+ IReadOnlyList Items,
+ bool IsTruncated,
+ int PageSize,
+ int PageCount,
+ int MaximumPageCount,
+ string ErrorMessage);
+
+public sealed record LegacyExpertActionView(
+ string ActionId,
+ string Label,
+ bool IsAvailable);
+
+public sealed record LegacyExpertWorkflowSnapshot(
+ long Revision,
+ LegacyExpertSearchSnapshot Search,
+ LegacyExpertResultView? SelectedExpert,
+ LegacyExpertPreviewSnapshot Preview,
+ IReadOnlyList Actions,
+ string StatusMessage,
+ LegacyOperatorStatusKind StatusKind);
+
+public sealed record LegacyExpertPlaylistDraft(
+ string DraftId,
+ string BuilderKey,
+ string CutCode,
+ IReadOnlyList Aliases,
+ string Title,
+ string Detail,
+ string Category,
+ string Source,
+ string ExpertCode,
+ string ExpertName,
+ int ItemCount,
+ bool PreviewTruncated,
+ int PageSize,
+ int PageCount,
+ int MaximumPageCount,
+ string PageText,
+ LegacySceneSelection Selection,
+ bool IsEnabled,
+ int FadeDuration,
+ string ActionId)
+{
+ public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
+ new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
+}
+
+///
+/// Native authority for UC6's read-only expert selector and recommendation preview.
+/// EList CRUD is intentionally outside this workflow.
+///
+public sealed class LegacyExpertWorkflowController
+{
+ public const string ActionId = "five-row-current";
+ public const int PageSize = 5;
+ public const int MaximumPageCount = 20;
+ public const int MaximumAccessibleItems = PageSize * MaximumPageCount;
+
+ private const int DefaultFadeDuration = 6;
+ private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가";
+
+ private static readonly ReadOnlyCollection Aliases =
+ Array.AsReadOnly(new[] { "5074" });
+
+ private readonly object _gate = new();
+ private readonly IExpertSelectionService _service;
+ private List _searchResults = [];
+ private ExpertState? _selected;
+ private List _previewItems = [];
+ private string _query = string.Empty;
+ private string _searchError = string.Empty;
+ private string _previewError = string.Empty;
+ private string _statusMessage = string.Empty;
+ private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle;
+ private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle;
+ private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
+ private bool _searchTruncated;
+ private bool _previewTruncated;
+ private int _searchGeneration;
+ private int _previewGeneration;
+ private long _revision;
+ private long _draftSequence;
+
+ public LegacyExpertWorkflowController(IExpertSelectionService service)
+ {
+ _service = service ?? throw new ArgumentNullException(nameof(service));
+ }
+
+ public LegacyExpertWorkflowSnapshot Current
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return CreateSnapshot();
+ }
+ }
+ }
+
+ public async Task SearchAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedQuery = NormalizeQuery(query);
+ int generation;
+ lock (_gate)
+ {
+ generation = ++_searchGeneration;
+ _previewGeneration++;
+ _query = normalizedQuery;
+ _searchResults = [];
+ _selected = null;
+ ResetPreview();
+ _searchStatus = LegacyExpertRequestStatus.Loading;
+ _searchError = string.Empty;
+ Touch();
+ }
+
+ try
+ {
+ var result = await _service.SearchAsync(
+ normalizedQuery,
+ LegacyExpertSelectionService.MaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ lock (_gate)
+ {
+ if (generation != _searchGeneration)
+ {
+ return CreateSnapshot();
+ }
+
+ _searchResults = ValidateSearchResult(result, normalizedQuery, generation);
+ _searchTruncated = result.IsTruncated;
+ _searchStatus = LegacyExpertRequestStatus.Ready;
+ _searchError = string.Empty;
+ _statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ lock (_gate)
+ {
+ if (generation == _searchGeneration)
+ {
+ _searchStatus = LegacyExpertRequestStatus.Idle;
+ _searchError = string.Empty;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ catch
+ {
+ lock (_gate)
+ {
+ if (generation == _searchGeneration)
+ {
+ _searchResults = [];
+ _searchStatus = LegacyExpertRequestStatus.Error;
+ _searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다.";
+ _statusMessage = _searchError;
+ _statusKind = LegacyOperatorStatusKind.Error;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ }
+
+ public async Task SelectExpertAsync(
+ string selectionId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ ExpertState selected;
+ int generation;
+ lock (_gate)
+ {
+ selected = _searchResults.FirstOrDefault(item =>
+ string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
+ throw new ArgumentOutOfRangeException(
+ nameof(selectionId),
+ "The expert selection id is not in the current result set.");
+ _selected = selected;
+ generation = ++_previewGeneration;
+ _previewItems = [];
+ _previewTruncated = false;
+ _previewStatus = LegacyExpertRequestStatus.Loading;
+ _previewError = string.Empty;
+ Touch();
+ }
+
+ try
+ {
+ var result = await _service.PreviewAsync(
+ selected.Identity,
+ LegacyExpertSelectionService.MaximumPreviewItems,
+ cancellationToken).ConfigureAwait(false);
+ lock (_gate)
+ {
+ if (generation != _previewGeneration ||
+ _selected?.Identity != selected.Identity)
+ {
+ return CreateSnapshot();
+ }
+
+ _previewItems = ValidatePreviewResult(result, selected.Identity);
+ _previewTruncated = result.IsTruncated;
+ _previewStatus = LegacyExpertRequestStatus.Ready;
+ _previewError = string.Empty;
+ _statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ lock (_gate)
+ {
+ if (generation == _previewGeneration)
+ {
+ _previewStatus = LegacyExpertRequestStatus.Idle;
+ _previewError = string.Empty;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ catch
+ {
+ lock (_gate)
+ {
+ if (generation == _previewGeneration)
+ {
+ _previewItems = [];
+ _previewTruncated = false;
+ _previewStatus = LegacyExpertRequestStatus.Error;
+ _previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다.";
+ _statusMessage = _previewError;
+ _statusKind = LegacyOperatorStatusKind.Error;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ }
+
+ public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ lock (_gate)
+ {
+ if (!string.Equals(actionId, ActionId, StringComparison.Ordinal))
+ {
+ throw new ArgumentOutOfRangeException(nameof(actionId));
+ }
+
+ if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready)
+ {
+ throw new InvalidOperationException("Select and preview an expert first.");
+ }
+
+ var itemCount = _previewItems.Count;
+ if (itemCount is <= 0 or > MaximumAccessibleItems)
+ {
+ throw new InvalidOperationException(
+ "An expert cut requires one through 100 accessible recommendation rows.");
+ }
+
+ var pageCount = checked((itemCount + PageSize - 1) / PageSize);
+ if (pageCount is <= 0 or > MaximumPageCount)
+ {
+ throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
+ }
+
+ var identity = _selected.Identity;
+ var selection = new LegacySceneSelection(
+ "PAGED_EXPERT",
+ identity.ExpertName,
+ "INPUT_ORDER",
+ "CURRENT",
+ identity.ExpertCode);
+ return new LegacyExpertPlaylistDraft(
+ $"expert-draft-{++_draftSequence:D8}",
+ "s5074",
+ "5074",
+ Aliases,
+ identity.ExpertName,
+ ActionLabel,
+ "expert",
+ "legacy-expert-workflow",
+ identity.ExpertCode,
+ identity.ExpertName,
+ itemCount,
+ _previewTruncated,
+ PageSize,
+ pageCount,
+ MaximumPageCount,
+ $"1/{pageCount}",
+ selection,
+ true,
+ DefaultFadeDuration,
+ ActionId);
+ }
+ }
+
+ private LegacyExpertWorkflowSnapshot CreateSnapshot()
+ {
+ var searchRows = _searchResults.Select(ToView).ToArray();
+ var previewRows = _previewItems.Select(item =>
+ new LegacyExpertRecommendationView(
+ item.PlayIndex,
+ item.StockCode,
+ item.StockName,
+ item.BuyAmount)).ToArray();
+ var pageCount = previewRows.Length == 0
+ ? 0
+ : (previewRows.Length + PageSize - 1) / PageSize;
+ var actionAvailable = _selected is not null &&
+ _previewStatus == LegacyExpertRequestStatus.Ready &&
+ previewRows.Length is > 0 and <= MaximumAccessibleItems &&
+ pageCount <= MaximumPageCount;
+ return new LegacyExpertWorkflowSnapshot(
+ _revision,
+ new LegacyExpertSearchSnapshot(
+ _query,
+ _searchStatus,
+ Array.AsReadOnly(searchRows),
+ _searchTruncated,
+ _searchError),
+ _selected is null ? null : ToView(_selected),
+ new LegacyExpertPreviewSnapshot(
+ _previewStatus,
+ Array.AsReadOnly(previewRows),
+ _previewTruncated,
+ PageSize,
+ pageCount,
+ MaximumPageCount,
+ _previewError),
+ Array.AsReadOnly(new[]
+ {
+ new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable)
+ }),
+ _statusMessage,
+ _statusKind);
+ }
+
+ private static List ValidateSearchResult(
+ ExpertSearchResult result,
+ string expectedQuery,
+ int generation)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
+ result.Items is null ||
+ result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
+ result.IsTruncated &&
+ result.Items.Count != LegacyExpertSelectionService.MaximumResults)
+ {
+ throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
+ }
+
+ var states = new List(result.Items.Count);
+ var codes = new HashSet(StringComparer.Ordinal);
+ var names = new HashSet(StringComparer.Ordinal);
+ ExpertSelectionIdentity? previous = null;
+ for (var index = 0; index < result.Items.Count; index++)
+ {
+ var item = result.Items[index] ??
+ throw new ExpertSelectionDataException("The expert search row is missing.");
+ if (item.Source != DataSourceKind.Oracle)
+ {
+ throw new ExpertSelectionDataException("Only Oracle expert identities are selectable.");
+ }
+
+ ValidateExpertIdentity(item.Identity);
+ if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
+ previous is not null && CompareIdentities(previous, item.Identity) > 0)
+ {
+ throw new ExpertSelectionDataException(
+ "Expert identities must be unique and sorted by name then code.");
+ }
+
+ states.Add(new ExpertState(
+ $"expert-result-{generation:D4}-{index + 1:D4}",
+ item.Identity));
+ previous = item.Identity;
+ }
+
+ return states;
+ }
+
+ private static List ValidatePreviewResult(
+ ExpertPreviewResult result,
+ ExpertSelectionIdentity expectedIdentity)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ ValidateExpertIdentity(result.Identity);
+ if (result.Identity != expectedIdentity || result.Items is null ||
+ result.Items.Count > MaximumAccessibleItems ||
+ result.IsTruncated && result.Items.Count != MaximumAccessibleItems)
+ {
+ throw new ExpertSelectionDataException("The expert preview response correlation is invalid.");
+ }
+
+ var items = new List(result.Items.Count);
+ var indexes = new HashSet();
+ var codes = new HashSet(StringComparer.Ordinal);
+ var names = new HashSet(StringComparer.Ordinal);
+ var previousIndex = -1;
+ foreach (var item in result.Items)
+ {
+ if (item is null || item.PlayIndex is < 0 or > 9_999 ||
+ item.PlayIndex <= previousIndex ||
+ !IsAsciiAlphaNumeric(item.StockCode, 32) ||
+ !IsCanonicalText(item.StockName, 200) ||
+ item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
+ decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
+ !indexes.Add(item.PlayIndex) ||
+ !codes.Add(item.StockCode) ||
+ !names.Add(item.StockName))
+ {
+ throw new ExpertSelectionDataException(
+ "The expert preview contains an invalid or duplicate recommendation.");
+ }
+
+ items.Add(item);
+ previousIndex = item.PlayIndex;
+ }
+
+ return items;
+ }
+
+ private static void ValidateExpertIdentity(ExpertSelectionIdentity identity)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+ if (identity.ExpertCode.Length != 4 ||
+ identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) ||
+ !IsCanonicalText(identity.ExpertName, 128))
+ {
+ throw new ExpertSelectionDataException("The expert identity is invalid.");
+ }
+ }
+
+ private static int CompareIdentities(
+ ExpertSelectionIdentity left,
+ ExpertSelectionIdentity right)
+ {
+ var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal);
+ return name != 0
+ ? name
+ : string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal);
+ }
+
+ private static LegacyExpertResultView ToView(ExpertState state) =>
+ new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName);
+
+ private static string NormalizeQuery(string query)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsSafeText(query))
+ {
+ throw new ArgumentException("The expert query contains an unsafe character.", nameof(query));
+ }
+
+ var normalized = query.Trim();
+ if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength ||
+ !IsSafeText(normalized))
+ {
+ throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query));
+ }
+
+ return normalized;
+ }
+
+ private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
+ value is not null && value.Length is > 0 && value.Length <= maximumLength &&
+ value.All(static character => char.IsAsciiLetterOrDigit(character));
+
+ private static bool IsCanonicalText(string? value, int maximumLength) =>
+ value is not null && value.Length is > 0 && value.Length <= maximumLength &&
+ string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
+
+ private static bool IsSafeText(string value)
+ {
+ foreach (var character in value)
+ {
+ var category = CharUnicodeInfo.GetUnicodeCategory(character);
+ if (char.IsControl(character) || char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ category is UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void ResetPreview()
+ {
+ _previewItems = [];
+ _previewTruncated = false;
+ _previewStatus = LegacyExpertRequestStatus.Idle;
+ _previewError = string.Empty;
+ }
+
+ private void Touch() => _revision++;
+
+ private sealed record ExpertState(
+ string SelectionId,
+ ExpertSelectionIdentity Identity);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs
new file mode 100644
index 0000000..3287e96
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs
@@ -0,0 +1,809 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyFixedMarket
+{
+ Overseas,
+ Exchange,
+ Index
+}
+
+public enum LegacyFixedCategory
+{
+ Plate,
+ Market,
+ Chart
+}
+
+public sealed record LegacyFixedSelection(
+ string GroupCode,
+ string Subject,
+ string GraphicType,
+ string Subtype,
+ string DataCode);
+
+public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20);
+
+public sealed record LegacyFixedRuntimeAsset(
+ LegacyFixedMarket Market,
+ string RelativePath,
+ string Sha256);
+
+public sealed class LegacyFixedAction
+{
+ internal LegacyFixedAction(
+ string id,
+ LegacyFixedMarket market,
+ string section,
+ string label,
+ string detail,
+ LegacyFixedCategory category,
+ string builderKey,
+ string code,
+ IReadOnlyList aliases,
+ bool available,
+ string prerequisite,
+ LegacyFixedSelection selection,
+ bool dynamicDate,
+ bool dynamicNxtSession)
+ {
+ Id = id;
+ Market = market;
+ Section = section;
+ Label = label;
+ Detail = detail;
+ Category = category;
+ BuilderKey = builderKey;
+ Code = code;
+ Aliases = aliases;
+ Available = available;
+ Prerequisite = prerequisite;
+ Selection = selection;
+ DynamicDate = dynamicDate;
+ DynamicNxtSession = dynamicNxtSession;
+ }
+
+ public string Id { get; }
+
+ public LegacyFixedMarket Market { get; }
+
+ public string Section { get; }
+
+ public string Label { get; }
+
+ public string Detail { get; }
+
+ public LegacyFixedCategory Category { get; }
+
+ public string BuilderKey { get; }
+
+ public string Code { get; }
+
+ public IReadOnlyList Aliases { get; }
+
+ public bool Available { get; }
+
+ public string Prerequisite { get; }
+
+ public LegacyFixedSelection Selection { get; }
+
+ public bool DynamicDate { get; }
+
+ public bool DynamicNxtSession { get; }
+
+ public bool SupportsMovingAverages =>
+ string.Equals(BuilderKey, "s8010", StringComparison.Ordinal);
+}
+
+public sealed record LegacyMaterializedFixedAction(
+ LegacyFixedAction Action,
+ LegacyFixedSelection Selection,
+ LegacyMovingAverageSelection MovingAverages,
+ LegacyFixedRuntimeAsset RuntimeAsset);
+
+public sealed class LegacyFixedSection
+{
+ internal LegacyFixedSection(
+ LegacyFixedMarket market,
+ string name,
+ IReadOnlyList actions)
+ {
+ Market = market;
+ Name = name;
+ Actions = actions;
+ }
+
+ public LegacyFixedMarket Market { get; }
+
+ public string Name { get; }
+
+ public IReadOnlyList Actions { get; }
+}
+
+///
+/// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a
+/// generated build asset; callers can select only by the opaque action id and cannot provide
+/// builder, cut alias, or scene-selection values.
+///
+public sealed class LegacyFixedActionCatalog
+{
+ public const int ExpectedActionCount = 328;
+ public const int ExpectedOverseasCount = 78;
+ public const int ExpectedExchangeCount = 46;
+ public const int ExpectedIndexCount = 204;
+ public const int ExpectedAvailableCount = 322;
+ public const string ExpectedDocumentSha256 =
+ "E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56";
+ public const string ExpectedGeneratorSourceSha256 =
+ "31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5";
+
+ private const string ResourceName =
+ "MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json";
+ private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js";
+
+ private static readonly JsonSerializerOptions StrictJsonOptions = new()
+ {
+ AllowTrailingCommas = false,
+ PropertyNameCaseInsensitive = false,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
+ };
+
+ private static readonly Lazy EmbeddedCatalog =
+ new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication);
+
+ private readonly IReadOnlyDictionary _byId;
+ private readonly IReadOnlyDictionary>
+ _byMarket;
+ private readonly IReadOnlyDictionary>
+ _sectionsByMarket;
+ private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
+ IReadOnlyList> _bySection;
+
+ private LegacyFixedActionCatalog(
+ IReadOnlyList actions,
+ IReadOnlyDictionary runtimeAssets,
+ string documentSha256,
+ string generatorSourceSha256)
+ {
+ Actions = actions;
+ RuntimeAssets = runtimeAssets;
+ DocumentSha256 = documentSha256;
+ GeneratorSourceSha256 = generatorSourceSha256;
+
+ _byId = new ReadOnlyDictionary(
+ actions.ToDictionary(action => action.Id, StringComparer.Ordinal));
+
+ _byMarket = new ReadOnlyDictionary>(
+ Enum.GetValues().ToDictionary(
+ market => market,
+ market => ReadOnly(actions.Where(action => action.Market == market))));
+
+ var sectionsByMarket = new Dictionary>();
+ var bySection = new Dictionary<(LegacyFixedMarket Market, string Section),
+ IReadOnlyList>();
+ foreach (var market in Enum.GetValues())
+ {
+ var sections = _byMarket[market]
+ .GroupBy(action => action.Section, StringComparer.Ordinal)
+ .Select(group =>
+ {
+ var sectionActions = ReadOnly(group);
+ bySection.Add((market, group.Key), sectionActions);
+ return new LegacyFixedSection(market, group.Key, sectionActions);
+ });
+ sectionsByMarket.Add(market, ReadOnly(sections));
+ }
+
+ _sectionsByMarket =
+ new ReadOnlyDictionary>(
+ sectionsByMarket);
+ _bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
+ IReadOnlyList>(bySection);
+ }
+
+ public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value;
+
+ public IReadOnlyList Actions { get; }
+
+ public IReadOnlyDictionary RuntimeAssets { get; }
+
+ public string DocumentSha256 { get; }
+
+ public string GeneratorSourceSha256 { get; }
+
+ public IReadOnlyList GetActions(LegacyFixedMarket market)
+ {
+ RequireMarket(market);
+ return _byMarket[market];
+ }
+
+ public IReadOnlyList GetSections(LegacyFixedMarket market)
+ {
+ RequireMarket(market);
+ return _sectionsByMarket[market];
+ }
+
+ public IReadOnlyList GetActions(
+ LegacyFixedMarket market,
+ string section)
+ {
+ RequireMarket(market);
+ ArgumentException.ThrowIfNullOrWhiteSpace(section);
+ return _bySection.TryGetValue((market, section), out var actions)
+ ? actions
+ : Array.Empty();
+ }
+
+ public LegacyFixedAction? FindAction(string? actionId) =>
+ actionId is not null && _byId.TryGetValue(actionId, out var action)
+ ? action
+ : null;
+
+ public LegacyFixedAction GetAction(string actionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
+ return _byId.TryGetValue(actionId, out var action)
+ ? action
+ : throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'.");
+ }
+
+ public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market)
+ {
+ RequireMarket(market);
+ return RuntimeAssets[market];
+ }
+
+ public LegacyMaterializedFixedAction Materialize(
+ string actionId,
+ DateTimeOffset localNow,
+ LegacyMovingAverageSelection movingAverages = default)
+ {
+ var action = GetAction(actionId);
+ if (!action.Available)
+ {
+ throw new InvalidOperationException(action.Prerequisite);
+ }
+
+ var trustedMovingAverages = action.SupportsMovingAverages
+ ? movingAverages
+ : default;
+ var graphicType = action.DynamicNxtSession
+ ? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
+ : action.SupportsMovingAverages
+ ? MovingAverageText(trustedMovingAverages)
+ : action.Selection.GraphicType;
+ var dataCode = action.DynamicDate
+ ? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+ : action.Selection.DataCode;
+
+ var selection = action.Selection with
+ {
+ GraphicType = graphicType,
+ DataCode = dataCode
+ };
+ return new LegacyMaterializedFixedAction(
+ action,
+ selection,
+ trustedMovingAverages,
+ RuntimeAssets[action.Market]);
+ }
+
+ internal static LegacyFixedActionCatalog LoadForValidation(
+ ReadOnlyMemory utf8Json,
+ string? expectedDocumentSha256 = null) =>
+ Load(utf8Json.ToArray(), expectedDocumentSha256);
+
+ private static LegacyFixedActionCatalog LoadEmbedded()
+ {
+ using var stream = typeof(LegacyFixedActionCatalog).Assembly
+ .GetManifestResourceStream(ResourceName)
+ ?? throw new InvalidDataException(
+ $"The embedded fixed legacy catalog '{ResourceName}' is missing.");
+ using var buffer = new MemoryStream();
+ stream.CopyTo(buffer);
+ return Load(buffer.ToArray(), ExpectedDocumentSha256);
+ }
+
+ private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256)
+ {
+ ArgumentNullException.ThrowIfNull(utf8Json);
+ if (utf8Json.Length == 0)
+ {
+ throw new InvalidDataException("The fixed legacy catalog is empty.");
+ }
+
+ CatalogDocumentDto document;
+ try
+ {
+ using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow
+ });
+ RejectDuplicateProperties(parsed.RootElement, "$root");
+ document = JsonSerializer.Deserialize(utf8Json, StrictJsonOptions)
+ ?? throw new InvalidDataException("The fixed legacy catalog root is null.");
+ }
+ catch (JsonException exception)
+ {
+ throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception);
+ }
+
+ var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json));
+ if (expectedDocumentSha256 is not null &&
+ !string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ $"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}.");
+ }
+
+ ValidateDocumentMetadata(document);
+ var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets);
+ var actions = CreateActions(document.Actions);
+ ValidateCatalogInvariants(actions);
+ return new LegacyFixedActionCatalog(
+ ReadOnly(actions),
+ runtimeAssets,
+ documentHash,
+ document.SourceSha256);
+ }
+
+ private static void ValidateDocumentMetadata(CatalogDocumentDto document)
+ {
+ if (document.SchemaVersion != 1)
+ {
+ throw Invalid("schemaVersion must be 1");
+ }
+
+ if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal))
+ {
+ throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'");
+ }
+
+ if (!string.Equals(
+ document.SourceSha256,
+ ExpectedGeneratorSourceSha256,
+ StringComparison.Ordinal))
+ {
+ throw Invalid("the generator source hash is not approved");
+ }
+
+ if (document.RuntimeAssets is null)
+ {
+ throw Invalid("runtimeAssets is required");
+ }
+
+ if (document.Actions is null)
+ {
+ throw Invalid("actions is required");
+ }
+ }
+
+ private static IReadOnlyDictionary
+ CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets)
+ {
+ var assets = new Dictionary
+ {
+ [LegacyFixedMarket.Overseas] = ValidateRuntimeAsset(
+ LegacyFixedMarket.Overseas,
+ runtimeAssets.Overseas,
+ "bin/Debug/Res/해외.ini",
+ "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
+ [LegacyFixedMarket.Exchange] = ValidateRuntimeAsset(
+ LegacyFixedMarket.Exchange,
+ runtimeAssets.Exchange,
+ "bin/Debug/Res/환율.ini",
+ "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
+ [LegacyFixedMarket.Index] = ValidateRuntimeAsset(
+ LegacyFixedMarket.Index,
+ runtimeAssets.Index,
+ "bin/Debug/Res/지수.ini",
+ "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
+ };
+ return new ReadOnlyDictionary(assets);
+ }
+
+ private static LegacyFixedRuntimeAsset ValidateRuntimeAsset(
+ LegacyFixedMarket market,
+ RuntimeAssetDto asset,
+ string expectedPath,
+ string expectedSha256)
+ {
+ if (asset is null ||
+ !string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) ||
+ !string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal))
+ {
+ throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid");
+ }
+
+ RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256");
+ return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256);
+ }
+
+ private static LegacyFixedAction[] CreateActions(IReadOnlyList source)
+ {
+ if (source.Count != ExpectedActionCount)
+ {
+ throw Invalid($"exactly {ExpectedActionCount} actions are required");
+ }
+
+ var actions = new LegacyFixedAction[source.Count];
+ for (var index = 0; index < source.Count; index++)
+ {
+ var dto = source[index]
+ ?? throw Invalid($"action at index {index} is null");
+ var expectedId = $"fixed-{index + 1:000}";
+ if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal))
+ {
+ throw Invalid($"action id at index {index} must be '{expectedId}'");
+ }
+
+ var market = ParseMarket(dto.Market);
+ var category = ParseCategory(dto.Category);
+ var section = RequireText(dto.Section, $"{expectedId}.section");
+ var label = RequireText(dto.Label, $"{expectedId}.label");
+ var detail = RequireText(dto.Detail, $"{expectedId}.detail");
+ var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey");
+ var code = RequireText(dto.Code, $"{expectedId}.code");
+ ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases);
+ var selection = ValidateSelection(expectedId, dto);
+
+ var prerequisite = RequireText(
+ dto.Prerequisite,
+ $"{expectedId}.prerequisite",
+ allowEmpty: true,
+ maximumLength: 512);
+ if (dto.Available != string.IsNullOrEmpty(prerequisite))
+ {
+ throw Invalid($"{expectedId} availability and prerequisite do not agree");
+ }
+
+ actions[index] = new LegacyFixedAction(
+ expectedId,
+ market,
+ section,
+ label,
+ detail,
+ category,
+ builderKey,
+ code,
+ Array.AsReadOnly(dto.Aliases.ToArray()),
+ dto.Available,
+ prerequisite,
+ selection,
+ dto.DynamicDate,
+ dto.DynamicNxtSession);
+ }
+
+ return actions;
+ }
+
+ private static void ValidateSceneIdentity(
+ string actionId,
+ string builderKey,
+ string code,
+ IReadOnlyList aliases)
+ {
+ if (aliases is null)
+ {
+ throw Invalid($"{actionId}.aliases is required");
+ }
+ var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
+ string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))
+ ?? throw Invalid($"{actionId} has unknown builder '{builderKey}'");
+
+ if (!definition.CutAliases.Contains(code, StringComparer.Ordinal))
+ {
+ throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'");
+ }
+
+ if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal))
+ {
+ throw Invalid($"{actionId} must declare exactly its code as its sole alias");
+ }
+
+ var aliasMatches = LegacySceneCatalog.FindByCutAlias(code);
+ if (!aliasMatches.Any(candidate =>
+ string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)))
+ {
+ throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'");
+ }
+ }
+
+ private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action)
+ {
+ var source = action.Selection
+ ?? throw Invalid($"{actionId}.selection is required");
+ var selection = new LegacyFixedSelection(
+ RequireText(source.GroupCode, $"{actionId}.selection.groupCode"),
+ RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true),
+ RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true),
+ RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true),
+ RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true));
+
+ if (action.DynamicDate && action.DynamicNxtSession)
+ {
+ throw Invalid($"{actionId} cannot have two dynamic selection fields");
+ }
+
+ if (action.DynamicDate !=
+ string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal))
+ {
+ throw Invalid($"{actionId} has an invalid dynamic-date declaration");
+ }
+
+ if (action.DynamicNxtSession !=
+ string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal))
+ {
+ throw Invalid($"{actionId} has an invalid NXT-session declaration");
+ }
+
+ if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) &&
+ !string.IsNullOrEmpty(selection.GraphicType))
+ {
+ throw Invalid($"{actionId} moving-average flags must be materialized by C#");
+ }
+
+ var values = new[]
+ {
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ selection.DataCode
+ };
+ if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) &&
+ !action.DynamicDate && !action.DynamicNxtSession)
+ {
+ throw Invalid($"{actionId} contains an unapproved selection placeholder");
+ }
+
+ return selection;
+ }
+
+ private static void ValidateCatalogInvariants(IReadOnlyList actions)
+ {
+ RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount);
+ RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount);
+ RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount);
+ if (actions.Count(action => action.Available) != ExpectedAvailableCount)
+ {
+ throw Invalid($"exactly {ExpectedAvailableCount} actions must be available");
+ }
+
+ if (actions.Take(ExpectedOverseasCount).Any(action =>
+ action.Market != LegacyFixedMarket.Overseas) ||
+ actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action =>
+ action.Market != LegacyFixedMarket.Exchange) ||
+ actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action =>
+ action.Market != LegacyFixedMarket.Index))
+ {
+ throw Invalid("market action ranges are not in the approved legacy order");
+ }
+ }
+
+ private static void RequireCount(
+ IEnumerable actions,
+ LegacyFixedMarket market,
+ int expected)
+ {
+ var actual = actions.Count(action => action.Market == market);
+ if (actual != expected)
+ {
+ throw Invalid(
+ $"market '{MarketText(market)}' must contain {expected} actions, not {actual}");
+ }
+ }
+
+ private static string MovingAverageText(LegacyMovingAverageSelection movingAverages)
+ {
+ if (movingAverages.Ma5 && movingAverages.Ma20)
+ {
+ return "MA5,MA20";
+ }
+
+ if (movingAverages.Ma5)
+ {
+ return "MA5";
+ }
+
+ return movingAverages.Ma20 ? "MA20" : string.Empty;
+ }
+
+ private static LegacyFixedMarket ParseMarket(string value) => value switch
+ {
+ "overseas" => LegacyFixedMarket.Overseas,
+ "exchange" => LegacyFixedMarket.Exchange,
+ "index" => LegacyFixedMarket.Index,
+ _ => throw Invalid($"unknown market '{value}'")
+ };
+
+ private static LegacyFixedCategory ParseCategory(string value) => value switch
+ {
+ "plate" => LegacyFixedCategory.Plate,
+ "market" => LegacyFixedCategory.Market,
+ "chart" => LegacyFixedCategory.Chart,
+ _ => throw Invalid($"unknown category '{value}'")
+ };
+
+ private static string MarketText(LegacyFixedMarket market) => market switch
+ {
+ LegacyFixedMarket.Overseas => "overseas",
+ LegacyFixedMarket.Exchange => "exchange",
+ LegacyFixedMarket.Index => "index",
+ _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.")
+ };
+
+ private static void RequireMarket(LegacyFixedMarket market)
+ {
+ if (!Enum.IsDefined(market))
+ {
+ throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.");
+ }
+ }
+
+ private static string RequireText(
+ string value,
+ string field,
+ bool allowEmpty = false,
+ int maximumLength = 256)
+ {
+ if (value is null || (!allowEmpty && value.Length == 0) ||
+ value.Length > maximumLength || value.Any(char.IsControl) ||
+ !string.Equals(value, value.Trim(), StringComparison.Ordinal))
+ {
+ throw Invalid($"{field} is invalid");
+ }
+
+ return value;
+ }
+
+ private static void RequireSha256(string value, string field)
+ {
+ if (value is null || value.Length != 64 || value.Any(character =>
+ !(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F')))
+ {
+ throw Invalid($"{field} is not an uppercase SHA-256 value");
+ }
+ }
+
+ private static void RejectDuplicateProperties(JsonElement element, string path)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ foreach (var property in element.EnumerateObject())
+ {
+ if (!names.Add(property.Name))
+ {
+ throw new JsonException($"Duplicate property '{property.Name}' at {path}.");
+ }
+
+ RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
+ }
+ }
+ else if (element.ValueKind == JsonValueKind.Array)
+ {
+ var index = 0;
+ foreach (var item in element.EnumerateArray())
+ {
+ RejectDuplicateProperties(item, $"{path}[{index++}]");
+ }
+ }
+ }
+
+ private static IReadOnlyList ReadOnly(IEnumerable source) =>
+ Array.AsReadOnly(source.ToArray());
+
+ private static InvalidDataException Invalid(string message) =>
+ new($"The fixed legacy catalog is invalid: {message}.");
+
+ private sealed class CatalogDocumentDto
+ {
+ [JsonPropertyName("schemaVersion")]
+ public required int SchemaVersion { get; init; }
+
+ [JsonPropertyName("generatedFrom")]
+ public required string GeneratedFrom { get; init; }
+
+ [JsonPropertyName("sourceSha256")]
+ public required string SourceSha256 { get; init; }
+
+ [JsonPropertyName("runtimeAssets")]
+ public required RuntimeAssetsDto RuntimeAssets { get; init; }
+
+ [JsonPropertyName("actions")]
+ public required List Actions { get; init; }
+ }
+
+ private sealed class RuntimeAssetsDto
+ {
+ [JsonPropertyName("overseas")]
+ public required RuntimeAssetDto Overseas { get; init; }
+
+ [JsonPropertyName("exchange")]
+ public required RuntimeAssetDto Exchange { get; init; }
+
+ [JsonPropertyName("index")]
+ public required RuntimeAssetDto Index { get; init; }
+ }
+
+ private sealed class RuntimeAssetDto
+ {
+ [JsonPropertyName("relativePath")]
+ public required string RelativePath { get; init; }
+
+ [JsonPropertyName("sha256")]
+ public required string Sha256 { get; init; }
+ }
+
+ private sealed class ActionDto
+ {
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("market")]
+ public required string Market { get; init; }
+
+ [JsonPropertyName("section")]
+ public required string Section { get; init; }
+
+ [JsonPropertyName("label")]
+ public required string Label { get; init; }
+
+ [JsonPropertyName("detail")]
+ public required string Detail { get; init; }
+
+ [JsonPropertyName("category")]
+ public required string Category { get; init; }
+
+ [JsonPropertyName("builderKey")]
+ public required string BuilderKey { get; init; }
+
+ [JsonPropertyName("code")]
+ public required string Code { get; init; }
+
+ [JsonPropertyName("aliases")]
+ public required List Aliases { get; init; }
+
+ [JsonPropertyName("available")]
+ public required bool Available { get; init; }
+
+ [JsonPropertyName("prerequisite")]
+ public required string Prerequisite { get; init; }
+
+ [JsonPropertyName("selection")]
+ public required SelectionDto Selection { get; init; }
+
+ [JsonPropertyName("dynamicDate")]
+ public required bool DynamicDate { get; init; }
+
+ [JsonPropertyName("dynamicNxtSession")]
+ public required bool DynamicNxtSession { get; init; }
+ }
+
+ private sealed class SelectionDto
+ {
+ [JsonPropertyName("groupCode")]
+ public required string GroupCode { get; init; }
+
+ [JsonPropertyName("subject")]
+ public required string Subject { get; init; }
+
+ [JsonPropertyName("graphicType")]
+ public required string GraphicType { get; init; }
+
+ [JsonPropertyName("subtype")]
+ public required string Subtype { get; init; }
+
+ [JsonPropertyName("dataCode")]
+ public required string DataCode { get; init; }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs
new file mode 100644
index 0000000..3f357bb
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs
@@ -0,0 +1,267 @@
+using MMoneyCoderSharp.Data;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyIndustryActionKind
+{
+ Single,
+ Pair,
+ Yield,
+ Candle,
+ FiveRow,
+ Square,
+ Sector
+}
+
+public enum LegacyIndustryCandleMode
+{
+ Price,
+ Volume
+}
+
+public sealed record LegacyIndustryRuntimeAsset(
+ IndustryMarket Market,
+ string RelativePath,
+ string Sha256);
+
+public sealed record LegacyIndustryActionDefinition(
+ IndustryMarket Market,
+ string ActionId,
+ string Label,
+ string Section,
+ string Category,
+ LegacyIndustryActionKind Kind,
+ string BuilderKey,
+ string SceneAlias,
+ int PageSize,
+ string? YieldPeriod = null,
+ LegacyIndustryCandleMode? CandleMode = null);
+
+///
+/// Closed projection of the two runtime industry INI files. The WebView supplies an
+/// action id only; market-specific aliases and page sizes remain authoritative in C#.
+///
+public static class LegacyIndustryActionCatalog
+{
+ public const int ActionsPerMarket = 22;
+
+ private static readonly LegacyIndustryRuntimeAsset KospiAsset = new(
+ IndustryMarket.Kospi,
+ "bin/Debug/Res/업종_코스피.ini",
+ "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
+
+ private static readonly LegacyIndustryRuntimeAsset KosdaqAsset = new(
+ IndustryMarket.Kosdaq,
+ "bin/Debug/Res/업종_코스닥.ini",
+ "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
+
+ private static readonly IReadOnlyList KospiActions =
+ CreateActions(IndustryMarket.Kospi);
+
+ private static readonly IReadOnlyList KosdaqActions =
+ CreateActions(IndustryMarket.Kosdaq);
+
+ private static readonly IReadOnlyDictionary
+ KospiActionsById = CreateLookup(KospiActions);
+
+ private static readonly IReadOnlyDictionary
+ KosdaqActionsById = CreateLookup(KosdaqActions);
+
+ public static LegacyIndustryRuntimeAsset GetRuntimeAsset(IndustryMarket market) => market switch
+ {
+ IndustryMarket.Kospi => KospiAsset,
+ IndustryMarket.Kosdaq => KosdaqAsset,
+ _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
+ };
+
+ public static IReadOnlyList GetActions(
+ IndustryMarket market) => market switch
+ {
+ IndustryMarket.Kospi => KospiActions,
+ IndustryMarket.Kosdaq => KosdaqActions,
+ _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
+ };
+
+ public static bool TryGetAction(
+ IndustryMarket market,
+ string actionId,
+ out LegacyIndustryActionDefinition? definition)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsSafeActionId(actionId))
+ {
+ definition = null;
+ return false;
+ }
+
+ return LookupFor(market).TryGetValue(actionId, out definition);
+ }
+
+ public static LegacyIndustryActionDefinition GetRequiredAction(
+ IndustryMarket market,
+ string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ return IsSafeActionId(actionId) &&
+ LookupFor(market).TryGetValue(actionId, out var definition)
+ ? definition
+ : throw new KeyNotFoundException("The industry action id is not registered.");
+ }
+
+ private static IReadOnlyDictionary LookupFor(
+ IndustryMarket market) => market switch
+ {
+ IndustryMarket.Kospi => KospiActionsById,
+ IndustryMarket.Kosdaq => KosdaqActionsById,
+ _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
+ };
+
+ private static bool IsSafeActionId(string actionId) =>
+ actionId.Length is > 0 and <= 64 &&
+ !actionId.Any(char.IsControl) &&
+ string.Equals(actionId, actionId.Trim(), StringComparison.Ordinal);
+
+ private static IReadOnlyList CreateActions(
+ IndustryMarket market)
+ {
+ var pairAlias = market == IndustryMarket.Kospi ? "8018" : "8032";
+ var squareAlias = market == IndustryMarket.Kospi ? "8001" : "8002";
+ var actions = new List(ActionsPerMarket)
+ {
+ Action(market, "one-column", "1열판", "업종 판", "plate",
+ LegacyIndustryActionKind.Single, "s5001", "5001"),
+ Action(market, "two-column", "2열판", "업종 판", "plate",
+ LegacyIndustryActionKind.Pair, "s8018", pairAlias)
+ };
+
+ AddYield(actions, market, "5일", "FiveDays");
+ AddYield(actions, market, "20일", "TwentyDays");
+ AddYield(actions, market, "60일", "SixtyDays");
+ AddYield(actions, market, "120일", "OneHundredTwentyDays");
+ AddYield(actions, market, "240일", "TwoHundredFortyDays");
+
+ AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Price);
+ AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Price);
+ AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Price);
+ AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Price);
+ AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Price);
+ AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Price);
+
+ AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Volume);
+ AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Volume);
+ AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Volume);
+ AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Volume);
+ AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Volume);
+ AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Volume);
+
+ actions.Add(Action(market, "five-row", "5단 표그래프", "시장 전체", "plate",
+ LegacyIndustryActionKind.FiveRow, "s5074", "5074", pageSize: 5));
+ actions.Add(Action(market, "square", "네모그래프", "시장 전체", "plate",
+ LegacyIndustryActionKind.Square, "s8001", squareAlias));
+ actions.Add(Action(market, "sector", "섹터지수", "시장 전체", "plate",
+ LegacyIndustryActionKind.Sector, "s5078", "5078"));
+
+ Validate(actions, market);
+ return Array.AsReadOnly(actions.ToArray());
+ }
+
+ private static void AddYield(
+ ICollection actions,
+ IndustryMarket market,
+ string periodLabel,
+ string period)
+ {
+ actions.Add(Action(
+ market,
+ $"yield-{periodLabel}",
+ $"수익률그래프_{periodLabel}",
+ "수익률 그래프",
+ "chart",
+ LegacyIndustryActionKind.Yield,
+ "s5086",
+ "5086",
+ yieldPeriod: period));
+ }
+
+ private static void AddCandle(
+ ICollection actions,
+ IndustryMarket market,
+ string periodLabel,
+ string sceneAlias,
+ LegacyIndustryCandleMode mode)
+ {
+ var volume = mode == LegacyIndustryCandleMode.Volume;
+ actions.Add(Action(
+ market,
+ $"candle-{(volume ? "volume-" : string.Empty)}{periodLabel}",
+ $"캔들그래프{(volume ? "(거래량)" : string.Empty)}_{periodLabel}",
+ volume ? "캔들 그래프 · 거래량" : "캔들 그래프",
+ "chart",
+ LegacyIndustryActionKind.Candle,
+ "s8010",
+ sceneAlias,
+ candleMode: mode));
+ }
+
+ private static LegacyIndustryActionDefinition Action(
+ IndustryMarket market,
+ string actionId,
+ string label,
+ string section,
+ string category,
+ LegacyIndustryActionKind kind,
+ string builderKey,
+ string sceneAlias,
+ int pageSize = 0,
+ string? yieldPeriod = null,
+ LegacyIndustryCandleMode? candleMode = null) => new(
+ market,
+ actionId,
+ label,
+ section,
+ category,
+ kind,
+ builderKey,
+ sceneAlias,
+ pageSize,
+ yieldPeriod,
+ candleMode);
+
+ private static IReadOnlyDictionary CreateLookup(
+ IReadOnlyList actions) =>
+ new System.Collections.ObjectModel.ReadOnlyDictionary<
+ string,
+ LegacyIndustryActionDefinition>(actions.ToDictionary(
+ action => action.ActionId,
+ StringComparer.Ordinal));
+
+ private static void Validate(
+ IReadOnlyCollection actions,
+ IndustryMarket market)
+ {
+ if (actions.Count != ActionsPerMarket ||
+ actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count() !=
+ ActionsPerMarket ||
+ actions.Any(action => action.Market != market) ||
+ actions.Any(action => action.PageSize is not (0 or 5)) ||
+ actions.Any(action =>
+ (action.Kind == LegacyIndustryActionKind.FiveRow) != (action.PageSize == 5)) ||
+ actions.Any(action => action.Kind == LegacyIndustryActionKind.Yield &&
+ string.IsNullOrEmpty(action.YieldPeriod)) ||
+ actions.Any(action => action.Kind != LegacyIndustryActionKind.Yield &&
+ action.YieldPeriod is not null) ||
+ actions.Any(action => action.Kind == LegacyIndustryActionKind.Candle &&
+ action.CandleMode is null) ||
+ actions.Any(action => action.Kind != LegacyIndustryActionKind.Candle &&
+ action.CandleMode is not null) ||
+ actions.Any(action => !IsSafeActionId(action.ActionId)) ||
+ actions.Any(action => !LegacySceneCatalog.FindByCutAlias(action.SceneAlias)
+ .Any(scene =>
+ string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
+ (int)scene.PageSize == action.PageSize)))
+ {
+ throw new InvalidOperationException("The legacy industry action catalog is invalid.");
+ }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs
new file mode 100644
index 0000000..96aa1d1
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs
@@ -0,0 +1,442 @@
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public sealed record LegacyIndustrySelectionSnapshot(
+ long Revision,
+ IndustryMarket? Market,
+ IReadOnlyList Industries,
+ IReadOnlyList Actions,
+ int? SelectedIndex,
+ IndustrySelection? SelectedIndustry,
+ IndustrySelection? FirstIndustry,
+ IndustrySelection? SecondIndustry);
+
+public sealed record LegacyIndustrySceneSelectionDraft(
+ string GroupCode,
+ string Subject,
+ string GraphicType,
+ string Subtype,
+ string DataCode);
+
+///
+/// Safe, generic playlist fields produced entirely from the C# catalog and the
+/// authoritative UC2 selection state. It intentionally has no caller-supplied row id.
+///
+public sealed record LegacyIndustryPlaylistDraft(
+ string ActionId,
+ string BuilderKey,
+ string SceneAlias,
+ int PageSize,
+ string Title,
+ string Detail,
+ string Category,
+ IndustryMarket Market,
+ LegacyIndustrySceneSelectionDraft Selection,
+ string RuntimeAssetRelativePath,
+ string RuntimeAssetSha256,
+ bool IsEnabled,
+ int FadeDuration);
+
+public sealed class LegacyIndustryWorkflowException : Exception
+{
+ public LegacyIndustryWorkflowException(string message) : base(message)
+ {
+ }
+}
+
+///
+/// C# authority for the legacy UC2 selector and the UC1 industry actions.
+///
+public sealed class LegacyIndustrySelectionWorkflow
+{
+ private const int DefaultFadeDuration = 6;
+
+ private static readonly IReadOnlyList NoIndustries =
+ Array.AsReadOnly(Array.Empty());
+
+ private static readonly IReadOnlyList NoActions =
+ Array.AsReadOnly(Array.Empty());
+
+ private readonly IIndustrySelectionService _selectionService;
+ private readonly TimeProvider _timeProvider;
+ private IReadOnlyList _industries = NoIndustries;
+ private IReadOnlyList _actions = NoActions;
+ private IndustryMarket? _market;
+ private int? _selectedIndex;
+ private IndustrySelection? _selectedIndustry;
+ private IndustrySelection? _firstIndustry;
+ private IndustrySelection? _secondIndustry;
+ private long _revision;
+ private long _loadGeneration;
+
+ public LegacyIndustrySelectionWorkflow(
+ IIndustrySelectionService selectionService,
+ TimeProvider? timeProvider = null)
+ {
+ _selectionService = selectionService ??
+ throw new ArgumentNullException(nameof(selectionService));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
+
+ public async Task LoadMarketAsync(
+ IndustryMarket market,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateMarket(market);
+ var generation = Interlocked.Increment(ref _loadGeneration);
+ var serviceRows = await _selectionService.GetAsync(market, cancellationToken)
+ .ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // A slower previous request must never replace a newer market request.
+ if (generation != Volatile.Read(ref _loadGeneration))
+ {
+ return CreateSnapshot();
+ }
+
+ var normalizedRows = NormalizeRows(serviceRows, market);
+ if (generation != Volatile.Read(ref _loadGeneration))
+ {
+ return CreateSnapshot();
+ }
+
+ var changingMarket = _market != market;
+ var previousSelected = changingMarket ? null : _selectedIndustry;
+ var previousFirst = changingMarket ? null : _firstIndustry;
+ var previousSecond = changingMarket ? null : _secondIndustry;
+
+ _market = market;
+ _industries = normalizedRows;
+ _actions = LegacyIndustryActionCatalog.GetActions(market);
+ _selectedIndustry = Reconcile(previousSelected, normalizedRows);
+ _selectedIndex = _selectedIndustry is null
+ ? null
+ : FindIdentityIndex(normalizedRows, _selectedIndustry);
+ _firstIndustry = Reconcile(previousFirst, normalizedRows);
+ _secondIndustry = Reconcile(previousSecond, normalizedRows);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyIndustrySelectionSnapshot SelectIndustry(int index)
+ {
+ if (!TryGetIndustry(index, out var industry))
+ {
+ return CreateSnapshot();
+ }
+
+ _selectedIndex = index;
+ _selectedIndustry = industry;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index)
+ {
+ if (!TryGetIndustry(index, out var industry))
+ {
+ return CreateSnapshot();
+ }
+
+ _selectedIndex = index;
+ _selectedIndustry = industry;
+
+ // UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
+ // double-clicked item to txt1.
+ _secondIndustry = _firstIndustry;
+ _firstIndustry = industry;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyIndustrySelectionSnapshot SwapIndustries()
+ {
+ if (_firstIndustry is null && _secondIndustry is null)
+ {
+ return CreateSnapshot();
+ }
+
+ (_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyIndustrySelectionSnapshot ClearSelections()
+ {
+ if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
+ {
+ return CreateSnapshot();
+ }
+
+ _selectedIndex = null;
+ _selectedIndustry = null;
+ _firstIndustry = null;
+ _secondIndustry = null;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (_market is not { } market)
+ {
+ throw new LegacyIndustryWorkflowException("Select an industry market first.");
+ }
+
+ if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) ||
+ definition is null)
+ {
+ throw new LegacyIndustryWorkflowException("The industry action is not registered.");
+ }
+
+ var projection = ProjectSelection(definition, market);
+ var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
+ return new LegacyIndustryPlaylistDraft(
+ definition.ActionId,
+ definition.BuilderKey,
+ definition.SceneAlias,
+ definition.PageSize,
+ projection.Title,
+ definition.Label,
+ definition.Category,
+ market,
+ projection.Selection,
+ asset.RelativePath,
+ asset.Sha256,
+ IsEnabled: true,
+ FadeDuration: DefaultFadeDuration);
+ }
+
+ private IndustryProjection ProjectSelection(
+ LegacyIndustryActionDefinition definition,
+ IndustryMarket market)
+ {
+ var industryGroup = market == IndustryMarket.Kospi
+ ? "INDUSTRY_KOSPI"
+ : "INDUSTRY_KOSDAQ";
+ var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ";
+
+ return definition.Kind switch
+ {
+ LegacyIndustryActionKind.Single => ForSelected(
+ definition,
+ industryGroup,
+ "1열판",
+ "CURRENT"),
+ LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup),
+ LegacyIndustryActionKind.Yield => ForSelected(
+ definition,
+ industryGroup,
+ "YIELD_GRAPH",
+ definition.YieldPeriod!),
+ LegacyIndustryActionKind.Candle => ForSelected(
+ definition,
+ market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY",
+ string.Empty,
+ definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"),
+ LegacyIndustryActionKind.FiveRow => new IndustryProjection(
+ $"{MarketLabel(market)} {definition.Label}",
+ new LegacyIndustrySceneSelectionDraft(
+ market == IndustryMarket.Kospi
+ ? "PAGED_DOMESTIC_KOSPI"
+ : "PAGED_DOMESTIC_KOSDAQ",
+ "INDEX",
+ string.Empty,
+ "INDUSTRY",
+ CurrentLocalDate())),
+ LegacyIndustryActionKind.Square => new IndustryProjection(
+ $"{MarketLabel(market)} {definition.Label}",
+ new LegacyIndustrySceneSelectionDraft(
+ industryGroup,
+ marketName,
+ "네모그래프",
+ string.Empty,
+ string.Empty)),
+ LegacyIndustryActionKind.Sector => new IndustryProjection(
+ $"{MarketLabel(market)} {definition.Label}",
+ new LegacyIndustrySceneSelectionDraft(
+ industryGroup,
+ marketName,
+ "SECTOR_INDEX",
+ string.Empty,
+ string.Empty)),
+ _ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.")
+ };
+ }
+
+ private IndustryProjection ForSelected(
+ LegacyIndustryActionDefinition definition,
+ string groupCode,
+ string graphicType,
+ string subtype)
+ {
+ if (_selectedIndustry is not { } selected)
+ {
+ throw new LegacyIndustryWorkflowException(
+ $"The {definition.ActionId} action requires one selected industry.");
+ }
+
+ return new IndustryProjection(
+ selected.IndustryName,
+ new LegacyIndustrySceneSelectionDraft(
+ groupCode,
+ selected.IndustryName,
+ graphicType,
+ subtype,
+ string.Empty));
+ }
+
+ private IndustryProjection ForPair(
+ LegacyIndustryActionDefinition definition,
+ string groupCode)
+ {
+ if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
+ first.IndustryName.Contains('-', StringComparison.Ordinal) ||
+ second.IndustryName.Contains('-', StringComparison.Ordinal))
+ {
+ throw new LegacyIndustryWorkflowException(
+ $"The {definition.ActionId} action requires two delimiter-safe industries.");
+ }
+
+ var subject = $"{first.IndustryName}-{second.IndustryName}";
+ return new IndustryProjection(
+ subject,
+ new LegacyIndustrySceneSelectionDraft(
+ groupCode,
+ subject,
+ "2열판",
+ string.Empty,
+ string.Empty));
+ }
+
+ private string CurrentLocalDate()
+ {
+ var local = _timeProvider.GetLocalNow();
+ return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ }
+
+ private bool TryGetIndustry(int index, out IndustrySelection industry)
+ {
+ if (index < 0 || index >= _industries.Count)
+ {
+ industry = null!;
+ return false;
+ }
+
+ industry = _industries[index];
+ return true;
+ }
+
+ private static IReadOnlyList NormalizeRows(
+ IReadOnlyList? rows,
+ IndustryMarket market)
+ {
+ if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults)
+ {
+ throw new LegacyIndustryWorkflowException("The industry list is invalid.");
+ }
+
+ var names = new HashSet(StringComparer.Ordinal);
+ var codes = new HashSet(StringComparer.Ordinal);
+ var normalized = new IndustrySelection[rows.Count];
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var row = rows[index];
+ if (row is null || row.Market != market)
+ {
+ throw new LegacyIndustryWorkflowException(
+ "The industry list contains a cross-market row.");
+ }
+
+ var name = row.IndustryName?.Trim();
+ var code = row.IndustryCode?.Trim();
+ if (string.IsNullOrEmpty(name) ||
+ name.Length > LegacyIndustrySelectionService.MaximumNameLength ||
+ name.Any(char.IsControl) ||
+ string.IsNullOrEmpty(code) ||
+ code.Length > 32 ||
+ code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
+ !names.Add(name) ||
+ !codes.Add(code))
+ {
+ throw new LegacyIndustryWorkflowException(
+ "The industry list contains an unsafe or ambiguous row.");
+ }
+
+ normalized[index] = new IndustrySelection(market, name, code);
+ }
+
+ return Array.AsReadOnly(normalized);
+ }
+
+ private static IndustrySelection? Reconcile(
+ IndustrySelection? previous,
+ IReadOnlyList rows)
+ {
+ if (previous is null)
+ {
+ return null;
+ }
+
+ return rows.FirstOrDefault(row => SameIdentity(row, previous));
+ }
+
+ private static int? FindIdentityIndex(
+ IReadOnlyList rows,
+ IndustrySelection selected)
+ {
+ for (var index = 0; index < rows.Count; index++)
+ {
+ if (SameIdentity(rows[index], selected))
+ {
+ return index;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool SameIdentity(IndustrySelection left, IndustrySelection right) =>
+ left.Market == right.Market &&
+ string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) &&
+ string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal);
+
+ private static void ValidateMarket(IndustryMarket market)
+ {
+ if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(market),
+ market,
+ "Unsupported industry market.");
+ }
+ }
+
+ private static string MarketLabel(IndustryMarket market) => market switch
+ {
+ IndustryMarket.Kospi => "코스피",
+ IndustryMarket.Kosdaq => "코스닥",
+ _ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
+ };
+
+ private LegacyIndustrySelectionSnapshot CreateSnapshot() => new(
+ _revision,
+ _market,
+ _industries,
+ _actions,
+ _selectedIndex,
+ _selectedIndustry,
+ _firstIndustry,
+ _secondIndustry);
+
+ private void Touch() => _revision++;
+
+ private sealed record IndustryProjection(
+ string Title,
+ LegacyIndustrySceneSelectionDraft Selection);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
index d071668..b52fe59 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
@@ -1,3 +1,6 @@
+using S5025ManualAudience =
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience;
+
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacySearchTrigger
@@ -14,6 +17,26 @@ public enum LegacyOperatorStatusKind
Error
}
+public enum LegacyPlaylistMoveDirection
+{
+ Up,
+ Down
+}
+
+public enum LegacyOperatorTabId
+{
+ Overseas,
+ Exchange,
+ Index,
+ KospiIndustry,
+ KosdaqIndustry,
+ Comparison,
+ Theme,
+ OverseasStocks,
+ Expert,
+ TradingHalt
+}
+
public sealed record LegacyStockResultRow(int Index, string DisplayName);
public sealed record LegacyCutViewRow(
@@ -25,6 +48,47 @@ public sealed record LegacyCutViewRow(
public sealed record LegacyDialogState(string Message);
+public sealed record LegacyOperatorCommandResult(
+ long Sequence,
+ string Command,
+ string TargetId,
+ bool Succeeded);
+
+public sealed record LegacyOperatorTabState(
+ LegacyOperatorTabId Id,
+ string Label,
+ bool IsActive);
+
+public sealed record LegacyFixedActionView(
+ string Id,
+ string Label,
+ string Detail,
+ bool IsAvailable,
+ string Prerequisite);
+
+public sealed record LegacyFixedSectionView(
+ int Index,
+ string Name,
+ IReadOnlyList Actions);
+
+public sealed record LegacyFixedCatalogView(
+ LegacyFixedMarket Market,
+ IReadOnlyList Sections);
+
+///
+/// A fixed UC1 parent-node activation is staged until every modal manual child has
+/// been confirmed. The playlist remains unchanged while this view is present.
+///
+public sealed record LegacyFixedSectionBatchView(
+ int SectionIndex,
+ string SectionName,
+ int TotalActionCount,
+ int StagedActionCount,
+ int ManualStepNumber,
+ int ManualStepCount,
+ string PendingActionId,
+ string PendingActionLabel);
+
public sealed record LegacyOperatorSnapshot(
long Revision,
string SearchText,
@@ -34,7 +98,22 @@ public sealed record LegacyOperatorSnapshot(
IReadOnlyList Playlist,
LegacyDialogState? Dialog,
string StatusMessage,
- LegacyOperatorStatusKind StatusKind);
+ LegacyOperatorStatusKind StatusKind,
+ IReadOnlyList? Tabs = null,
+ LegacyMovingAverageSelection MovingAverages = default,
+ LegacyFixedCatalogView? FixedCatalog = null,
+ LegacyIndustrySelectionSnapshot? Industry = null,
+ LegacyThemeWorkflowSnapshot? Theme = null,
+ LegacyExpertWorkflowSnapshot? Expert = null,
+ LegacyTradingHaltWorkflowSnapshot? TradingHalt = null,
+ LegacyComparisonWorkflowSnapshot? Comparison = null,
+ LegacyManualFinancialWorkflowSnapshot? ManualFinancial = null,
+ LegacyNamedPlaylistWorkflowSnapshot? NamedPlaylist = null,
+ LegacyOverseasWorkflowSnapshot? Overseas = null,
+ LegacyManualListsSnapshot? ManualLists = null,
+ LegacyOperatorCatalogWorkflowSnapshot? OperatorCatalog = null,
+ LegacyOperatorCommandResult? CommandResult = null,
+ LegacyFixedSectionBatchView? FixedSectionBatch = null);
///
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
@@ -45,9 +124,36 @@ public sealed class LegacyOperatorController
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
+ private readonly LegacyFixedActionCatalog _fixedCatalog;
+ private readonly TimeProvider _timeProvider;
+ private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow;
+ private readonly LegacyThemeWorkflow? _themeWorkflow;
+ private readonly LegacyExpertWorkflowController? _expertWorkflow;
+ private readonly LegacyTradingHaltWorkflowController? _tradingHaltWorkflow;
+ private readonly LegacyComparisonWorkflowController? _comparisonWorkflow;
+ private readonly LegacyManualFinancialWorkflow? _manualFinancialWorkflow;
+ private readonly LegacyNamedPlaylistWorkflowController? _namedPlaylistWorkflow;
+ private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
+ private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
+ private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
+ private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
private readonly SortedSet _legacyPointerRows = [];
private readonly SortedSet _nativeSelectedCutRows = [];
private readonly List _playlist = [];
+ private readonly HashSet _selectedPlaylistRowIds = new(StringComparer.Ordinal);
+ private readonly List _tabOrder =
+ [
+ LegacyOperatorTabId.Overseas,
+ LegacyOperatorTabId.Exchange,
+ LegacyOperatorTabId.Index,
+ LegacyOperatorTabId.KospiIndustry,
+ LegacyOperatorTabId.KosdaqIndustry,
+ LegacyOperatorTabId.Comparison,
+ LegacyOperatorTabId.Theme,
+ LegacyOperatorTabId.OverseasStocks,
+ LegacyOperatorTabId.Expert,
+ LegacyOperatorTabId.TradingHalt
+ ];
private Queue? _pendingDropCutRows;
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
private int? _selectedStockIndex;
@@ -57,20 +163,93 @@ public sealed class LegacyOperatorController
private int _previousPointerX;
private int _previousPointerY;
private long? _previousPointerTimestamp;
+ private string? _activePlaylistRowId;
+ private int _playlistCursorIndex;
+ private int _playlistPriorCursorIndex;
private long _revision;
private long _playlistSequence;
private string _searchText = string.Empty;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private LegacyDialogState? _dialog;
+ private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas;
+ private LegacyMovingAverageSelection _movingAverages = new(true, true);
+ private LegacyThemeWorkflowSnapshot? _themeSnapshot;
+ private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot;
+ private bool _comparisonLoaded;
+ private bool _themeActivationListLoaded;
+ private bool _expertActivationListLoaded;
+ private long _tabActivationRequest;
+ private long _commandSequence;
+ private LegacyOperatorCommandResult? _commandResult;
+ private PendingFixedSectionBatch? _pendingFixedSectionBatch;
- public LegacyOperatorController(ILegacyStockLookup stockLookup)
+ public LegacyOperatorController(
+ ILegacyStockLookup stockLookup,
+ LegacyFixedActionCatalog? fixedCatalog = null,
+ TimeProvider? timeProvider = null,
+ LegacyIndustrySelectionWorkflow? industryWorkflow = null,
+ LegacyThemeWorkflow? themeWorkflow = null,
+ LegacyExpertWorkflowController? expertWorkflow = null,
+ LegacyTradingHaltWorkflowController? tradingHaltWorkflow = null,
+ LegacyComparisonWorkflowController? comparisonWorkflow = null,
+ LegacyManualFinancialWorkflow? manualFinancialWorkflow = null,
+ LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null,
+ LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
+ LegacyManualListsWorkflow? manualListsWorkflow = null,
+ LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
+ _fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _industryWorkflow = industryWorkflow;
+ _themeWorkflow = themeWorkflow;
+ _themeSnapshot = themeWorkflow?.CreateInitial();
+ _expertWorkflow = expertWorkflow;
+ _tradingHaltWorkflow = tradingHaltWorkflow;
+ _comparisonWorkflow = comparisonWorkflow;
+ _manualFinancialWorkflow = manualFinancialWorkflow;
+ _namedPlaylistWorkflow = namedPlaylistWorkflow;
+ _overseasWorkflow = overseasWorkflow;
+ _manualListsWorkflow = manualListsWorkflow;
+ _operatorCatalogWorkflow = operatorCatalogWorkflow;
+ _tradingHaltWorkflow?.SetMovingAverages(
+ _movingAverages.Ma5,
+ _movingAverages.Ma20);
+ _overseasWorkflow?.SetMovingAverages(
+ _movingAverages.Ma5,
+ _movingAverages.Ma20);
}
public LegacyOperatorSnapshot Current => CreateSnapshot();
+ public LegacyOperatorPlayoutRequest CreatePlayoutRequest()
+ {
+ if (_playlist.Count == 0)
+ {
+ throw new InvalidOperationException("플레이리스트에 송출할 항목이 없습니다.");
+ }
+
+ var selectedIndex = _activePlaylistRowId is null
+ ? -1
+ : FindPlaylistIndex(_activePlaylistRowId);
+ if (selectedIndex < 0)
+ {
+ selectedIndex = _playlist.FindIndex(row =>
+ _selectedPlaylistRowIds.Contains(row.RowId));
+ }
+
+ if (selectedIndex < 0)
+ {
+ selectedIndex = Math.Clamp(_playlistCursorIndex, 0, _playlist.Count - 1);
+ }
+
+ var entries = _playlist.Select(static row => row.ToPlayoutEntry()).ToArray();
+ return new LegacyOperatorPlayoutRequest(
+ Array.AsReadOnly(entries),
+ selectedIndex);
+ }
+
public async Task SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
@@ -181,6 +360,2710 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public LegacyOperatorSnapshot TogglePlaylistRowEnabled(string rowId)
+ {
+ ArgumentNullException.ThrowIfNull(rowId);
+ var index = FindPlaylistIndex(rowId);
+ if (index >= 0)
+ {
+ _playlist[index] = _playlist[index] with
+ {
+ IsEnabled = !_playlist[index].IsEnabled
+ };
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SetAllPlaylistRowsEnabled(bool enabled)
+ {
+ if (_playlist.Count == 0)
+ {
+ return CreateSnapshot();
+ }
+
+ for (var index = 0; index < _playlist.Count; index++)
+ {
+ _playlist[index] = _playlist[index] with { IsEnabled = enabled };
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectTab(LegacyOperatorTabId tabId)
+ {
+ if (_pendingFixedSectionBatch is not null && _activeTab != tabId)
+ {
+ return ReportError(
+ "고정 컷 섹션의 수동 입력은 원본 모달 창과 같이 완료하거나 취소하기 전까지 탭을 바꿀 수 없습니다.");
+ }
+
+ if (!_tabOrder.Contains(tabId) || _activeTab == tabId)
+ {
+ return CreateSnapshot();
+ }
+
+ BeginTabActivation(tabId);
+ _activeTab = tabId;
+ Interlocked.Increment(ref _tabActivationRequest);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SelectTabAsync(
+ LegacyOperatorTabId tabId,
+ CancellationToken cancellationToken = default)
+ {
+ var changed = _activeTab != tabId;
+ SelectTab(tabId);
+ var activationRequest = Volatile.Read(ref _tabActivationRequest);
+ await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!IsCurrentTabActivation(tabId, activationRequest))
+ {
+ return CreateSnapshot();
+ }
+
+ var market = GetActiveIndustryMarket();
+ var touched = false;
+ if (_industryWorkflow is not null && market is not null &&
+ (changed || _industryWorkflow.Current.Market != market.Value))
+ {
+ await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken)
+ .ConfigureAwait(false);
+ if (!IsCurrentTabActivation(tabId, activationRequest))
+ {
+ return CreateSnapshot();
+ }
+
+ touched = true;
+ }
+
+ if (IsComparisonActive() && _comparisonWorkflow is not null && !_comparisonLoaded)
+ {
+ await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken)
+ .ConfigureAwait(false);
+ if (!IsCurrentTabActivation(tabId, activationRequest))
+ {
+ return CreateSnapshot();
+ }
+
+ _comparisonLoaded = true;
+ touched = true;
+ }
+
+ // Legacy UC4_Load starts a one-shot timer whose first tick calls
+ // GetThemeByKeyword with the initial empty query. Keep that load in
+ // native authority so Web code cannot decide when or what to query.
+ if (IsThemeActive() && _themeWorkflow is not null &&
+ _themeSnapshot is not null && !_themeActivationListLoaded)
+ {
+ var loaded = await _themeWorkflow.SearchAsync(
+ _themeSnapshot,
+ string.Empty,
+ MMoneyCoderSharp.Data.ThemeSession.PreMarket,
+ cancellationToken).ConfigureAwait(false);
+ if (!IsCurrentTabActivation(tabId, activationRequest))
+ {
+ return CreateSnapshot();
+ }
+
+ _themeSnapshot = loaded;
+ _themeActivationListLoaded = true;
+ touched = true;
+ }
+
+ // Legacy UC6_Load calls Data_Load immediately, which loads the full
+ // EXPERT_LIST. An empty native search is its parameterized equivalent.
+ if (IsExpertActive() && _expertWorkflow is not null &&
+ !_expertActivationListLoaded)
+ {
+ await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
+ .ConfigureAwait(false);
+ if (!IsCurrentTabActivation(tabId, activationRequest))
+ {
+ return CreateSnapshot();
+ }
+
+ _expertActivationListLoaded = true;
+ touched = true;
+ }
+
+ if (touched)
+ {
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ finally
+ {
+ _tabActivationGate.Release();
+ }
+ }
+
+ public LegacyOperatorSnapshot SwapTabs(
+ LegacyOperatorTabId source,
+ LegacyOperatorTabId target)
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "고정 컷 섹션의 수동 입력을 완료하거나 취소하기 전에는 탭 순서를 바꿀 수 없습니다.");
+ }
+
+ var sourceIndex = _tabOrder.IndexOf(source);
+ var targetIndex = _tabOrder.IndexOf(target);
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
+ {
+ return CreateSnapshot();
+ }
+
+ (_tabOrder[sourceIndex], _tabOrder[targetIndex]) =
+ (_tabOrder[targetIndex], _tabOrder[sourceIndex]);
+ if (_activeTab != source)
+ {
+ Interlocked.Increment(ref _tabActivationRequest);
+ BeginTabActivation(source);
+ }
+
+ _activeTab = source;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SwapTabsAsync(
+ LegacyOperatorTabId source,
+ LegacyOperatorTabId target,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_tabOrder.Contains(source) || !_tabOrder.Contains(target) || source == target)
+ {
+ return SwapTabs(source, target);
+ }
+
+ SwapTabs(source, target);
+ return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false);
+ }
+
+ public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20)
+ {
+ var next = new LegacyMovingAverageSelection(ma5, ma20);
+ if (_movingAverages == next)
+ {
+ return CreateSnapshot();
+ }
+
+ _movingAverages = next;
+ _tradingHaltWorkflow?.SetMovingAverages(ma5, ma20);
+ _overseasWorkflow?.SetMovingAverages(ma5, ma20);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task OpenOperatorCatalogAsync(
+ LegacyOperatorCatalogEntity entity,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.OpenAsync(entity, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task BeginThemeCatalogFromUc4Async(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.OpenAsync(
+ LegacyOperatorCatalogEntity.Theme,
+ cancellationToken).ConfigureAwait(false);
+ if (_operatorCatalogWorkflow.Current.CanBeginCreate)
+ {
+ await _operatorCatalogWorkflow.BeginCreateThemeAsync(
+ MMoneyCoderSharp.Data.ThemeMarket.Krx,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task EditSelectedThemeCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
+ }
+
+ var identity = _themeSnapshot?.SelectedIdentity;
+ if (identity is null)
+ {
+ return ReportError("편집할 테마를 먼저 선택하세요.");
+ }
+
+ await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task DeleteSelectedThemeCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await EditSelectedThemeCatalogAsync(cancellationToken).ConfigureAwait(false);
+ if (_operatorCatalogWorkflow?.Current.CanDelete == true)
+ {
+ await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public async Task BeginExpertCatalogFromUc6Async(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.OpenAsync(
+ LegacyOperatorCatalogEntity.Expert,
+ cancellationToken).ConfigureAwait(false);
+ if (_operatorCatalogWorkflow.Current.CanBeginCreate)
+ {
+ await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task EditSelectedExpertCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ var selected = _expertWorkflow?.Current.SelectedExpert;
+ if (selected is null)
+ {
+ return ReportError("편집할 전문가를 먼저 선택하세요.");
+ }
+
+ await _operatorCatalogWorkflow.OpenExpertForEditAsync(
+ new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
+ selected.ExpertCode,
+ selected.ExpertName),
+ cancellationToken).ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task DeleteSelectedExpertCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await EditSelectedExpertCatalogAsync(cancellationToken).ConfigureAwait(false);
+ if (_operatorCatalogWorkflow?.Current.CanDelete == true)
+ {
+ await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot CloseOperatorCatalog()
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return CreateSnapshot();
+ }
+
+ _operatorCatalogWorkflow.Close();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchOperatorCatalogAsync(
+ string query,
+ MMoneyCoderSharp.Data.ThemeSession nxtSession,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.SearchAsync(query, nxtSession, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SelectOperatorCatalogResultAsync(
+ string resultId,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.SelectAsync(resultId, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task BeginCreateThemeCatalogAsync(
+ MMoneyCoderSharp.Data.ThemeMarket market,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.BeginCreateThemeAsync(market, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task BeginCreateExpertCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchOperatorCatalogStocksAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.SearchStocksAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectOperatorCatalogStock(string resultId)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.SelectStock(resultId);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot AddOperatorCatalogStock(decimal? buyAmount)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.AddSelectedStock(buyAmount);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot MoveOperatorCatalogDraftRow(
+ string rowId,
+ LegacyOperatorCatalogMoveDirection direction)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.MoveDraftRow(rowId, direction);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
+ string rowId,
+ decimal buyAmount)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.SetDraftBuyAmount(rowId, buyAmount);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateOperatorCatalogStock(
+ string resultId,
+ decimal? buyAmount)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.SelectStock(resultId);
+ if (string.Equals(
+ _operatorCatalogWorkflow.Current.SelectedStockResultId,
+ resultId,
+ StringComparison.Ordinal))
+ {
+ _operatorCatalogWorkflow.AddSelectedStock(buyAmount);
+ }
+
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot RemoveOperatorCatalogDraftRow(string rowId)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ _operatorCatalogWorkflow.RemoveDraftRow(rowId);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SaveOperatorCatalogAsync(
+ string editorName,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task DeleteOperatorCatalogAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot InvalidateOperatorCatalogBrowserCorrelation()
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return CreateSnapshot();
+ }
+
+ _operatorCatalogWorkflow.InvalidateBrowserCorrelation();
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task OpenManualFinancialAsync(
+ MMoneyCoderSharp.Data.ManualFinancialScreenKind screen,
+ CancellationToken cancellationToken = default)
+ {
+ if (_manualFinancialWorkflow is null)
+ {
+ return ReportError("수동 재무 입력 기능이 구성되지 않았습니다.");
+ }
+
+ _manualFinancialSnapshot = _manualFinancialWorkflow.CreateInitial(screen);
+ _manualFinancialSnapshot = await _manualFinancialWorkflow.SearchAsync(
+ _manualFinancialSnapshot,
+ string.Empty,
+ cancellationToken).ConfigureAwait(false);
+ _statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot CloseManualFinancial()
+ {
+ if (_manualFinancialSnapshot is null)
+ {
+ return CreateSnapshot();
+ }
+
+ _manualFinancialSnapshot = null;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchManualFinancialAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ _manualFinancialSnapshot = await workflow.SearchAsync(
+ snapshot,
+ query,
+ cancellationToken).ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ToggleManualFinancialNameSort(long expectedRevision)
+ {
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("Open a manual-financial screen before sorting its list.");
+ }
+
+ try
+ {
+ _manualFinancialSnapshot = workflow.ToggleNameSort(
+ snapshot,
+ expectedRevision);
+ _statusMessage = "The GraphE stock-name order was changed.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ _statusMessage = exception.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task FindManualFinancialAsync(
+ string query,
+ LegacyManualFinancialFindDirection direction,
+ long expectedRevision,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("Open a manual-financial screen before finding a stock.");
+ }
+
+ try
+ {
+ _manualFinancialSnapshot = workflow.FindByName(
+ snapshot,
+ query,
+ direction,
+ expectedRevision);
+ if (_manualFinancialSnapshot.LastMessage is not null)
+ {
+ _statusMessage = _manualFinancialSnapshot.LastMessage;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ return await LoadManualFinancialAsync(
+ _manualFinancialSnapshot.SelectedRowId!,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException or KeyNotFoundException or
+ LegacyManualFinancialWorkflowDataException)
+ {
+ // A stale browser generation or a failed fresh detail read leaves the
+ // GraphE modal open. The operator may inspect the failure; no read is retried.
+ _dialog = new LegacyDialogState(exception.Message);
+ _statusMessage = exception.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task LoadManualFinancialAsync(
+ string resultId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(resultId);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ _manualFinancialSnapshot = await workflow.LoadAsync(
+ snapshot,
+ resultId,
+ cancellationToken).ConfigureAwait(false);
+
+ // GraphE identities contain STOCK_NAME only. Resolve the exact stock master
+ // immediately after the fresh detail read when it is unambiguous; otherwise
+ // leave the candidates visible so the operator can resolve them explicitly.
+ var stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.StockName;
+ _manualFinancialSnapshot = await workflow.SearchStocksAsync(
+ _manualFinancialSnapshot,
+ stockName,
+ cancellationToken).ConfigureAwait(false);
+ var exact = _manualFinancialSnapshot.StockCandidates
+ .Where(row => string.Equals(row.Name, stockName, StringComparison.Ordinal))
+ .ToArray();
+ if (exact.Length == 1)
+ {
+ try
+ {
+ _manualFinancialSnapshot = workflow.SelectStock(
+ _manualFinancialSnapshot,
+ exact[0].ResultId);
+ }
+ catch (ArgumentException)
+ {
+ // Ambiguous provider identities remain unverified and visible.
+ }
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task ActivateManualFinancialResultAsync(
+ string resultId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(resultId);
+ const string command = "activate-manual-financial-result";
+ if (!TryGetManualFinancial(out _, out _))
+ {
+ CompleteCommand(command, resultId, succeeded: false);
+ return ReportError("Open a manual-financial screen before activating a result.");
+ }
+
+ try
+ {
+ // A GraphE list identity is only an opaque handle. Re-read and validate the
+ // exact record and stock-master identity before deriving the playlist row.
+ await LoadManualFinancialAsync(resultId, cancellationToken).ConfigureAwait(false);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ throw new InvalidOperationException(
+ "The manual-financial result is no longer available.");
+ }
+
+ var draft = workflow.MaterializePlaylistDraft(
+ snapshot,
+ snapshot.Definition.ActionId);
+ AppendManualFinancialRow(draft);
+ _manualFinancialSnapshot = null;
+ _statusMessage = $"{draft.Title} {draft.Detail} cut was added.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ CompleteCommand(command, resultId, succeeded: true);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException or KeyNotFoundException or
+ LegacyManualFinancialWorkflowDataException)
+ {
+ // Keep the GraphE modal open and never append a partial row.
+ _dialog = new LegacyDialogState(exception.Message);
+ _statusMessage = exception.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ CompleteCommand(command, resultId, succeeded: false);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot BeginManualFinancialCreate()
+ {
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ _manualFinancialSnapshot = workflow.BeginCreate(snapshot);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchManualFinancialStocksAsync(
+ string stockName,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(stockName);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ _manualFinancialSnapshot = await workflow.SearchStocksAsync(
+ snapshot,
+ stockName,
+ cancellationToken).ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectManualFinancialStock(string resultId)
+ {
+ ArgumentNullException.ThrowIfNull(resultId);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ try
+ {
+ _manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task SaveManualFinancialAsync(
+ MMoneyCoderSharp.Data.ManualFinancialRecord record,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ try
+ {
+ LegacyManualFinancialWriteRequest request = snapshot.SelectedRecord is null
+ ? workflow.CreateCreateRequest(snapshot, record)
+ : workflow.CreateUpdateRequest(snapshot, record);
+ _manualFinancialSnapshot = await workflow.ExecuteAsync(
+ snapshot,
+ request,
+ cancellationToken).ConfigureAwait(false);
+ ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task DeleteManualFinancialAsync(
+ bool all,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ try
+ {
+ LegacyManualFinancialWriteRequest request = all
+ ? workflow.CreateDeleteAllRequest(
+ snapshot,
+ snapshot.Definition.DeleteAllConfirmationToken)
+ : workflow.CreateDeleteRequest(snapshot);
+ _manualFinancialSnapshot = await workflow.ExecuteAsync(
+ snapshot,
+ request,
+ cancellationToken).ConfigureAwait(false);
+ ApplyManualFinancialResultStatus(
+ _manualFinancialSnapshot,
+ all ? "전체 삭제" : "삭제");
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return ReportError("수동 재무 입력 화면을 먼저 여세요.");
+ }
+
+ try
+ {
+ var draft = workflow.MaterializePlaylistDraft(snapshot, actionId);
+ AppendManualFinancialRow(draft);
+ _statusMessage = $"{draft.Title} {draft.Detail} 컷을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task OpenManualNetSellAsync(
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
+ CancellationToken cancellationToken = default)
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
+ }
+
+ return await OpenManualNetSellCoreAsync(audience, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task OpenManualNetSellCoreAsync(
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
+ CancellationToken cancellationToken)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.OpenNetSellAsync(audience, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task OpenManualViAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
+ }
+
+ return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task OpenManualViCoreAsync(
+ CancellationToken cancellationToken)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.OpenViAsync(cancellationToken).ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot CloseManualLists()
+ {
+ _manualListsWorkflow?.Close();
+ if (_pendingFixedSectionBatch is { } pending)
+ {
+ _pendingFixedSectionBatch = null;
+ _statusMessage =
+ $"{pending.SectionName} 섹션 추가를 취소했습니다. 편성표에는 아무 항목도 추가하지 않았습니다. 이미 확인한 수동 저장 내용은 유지됩니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task RefreshManualListsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.RefreshAsync(cancellationToken).ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SetManualNetSellCell(
+ string rowId,
+ LegacyManualNetSellField field,
+ string value)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
+ }
+
+ _manualListsWorkflow.SetNetSellCell(rowId, field, value);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SaveManualNetSellAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
+ }
+
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.SaveNetSellAsync(cancellationToken).ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot AddManualNetSellPlaylistRow()
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
+ }
+
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
+ }
+
+ try
+ {
+ AppendManualListRow(_manualListsWorkflow.CreateNetSellPlaylistDraft());
+ _statusMessage = "검증된 수동 순매도 컷을 편성표에 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchManualViAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.SearchViAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectManualViSearchResult(string resultId)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("The manual VI list feature is unavailable.");
+ }
+
+ _manualListsWorkflow.SelectViSearchResult(resultId);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot AddManualViSearchResult(string resultId)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ _manualListsWorkflow.AddViSearchResult(resultId);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot MoveManualViItem(
+ string itemId,
+ LegacyManualListMoveDirection direction)
+ {
+ _manualListsWorkflow?.MoveViItem(itemId, direction);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot DeleteManualViItem(string itemId)
+ {
+ _manualListsWorkflow?.DeleteViItem(itemId);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot DeleteAllManualViItems()
+ {
+ _manualListsWorkflow?.DeleteAllViItems();
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SaveManualViAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
+ }
+
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.SaveViAsync(cancellationToken).ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot AddManualViPlaylistRow()
+ {
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
+ }
+
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ try
+ {
+ AppendManualListRow(_manualListsWorkflow.CreateViPlaylistDraft());
+ _statusMessage = "검증된 VI 발동 컷을 편성표에 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ ///
+ /// Reproduces the original FSell/VI OK button: persist once, verify the fresh
+ /// readback, append the corresponding cut, and close the editor as one operator
+ /// action. A failed or outcome-unknown save leaves the editor open and never
+ /// attempts playlist materialization or an automatic retry.
+ ///
+ public async Task ConfirmManualListsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ var screen = _manualListsWorkflow.Current.Screen;
+ if (screen is null)
+ {
+ return ReportError("열려 있는 수동 목록 입력창이 없습니다.");
+ }
+
+ if (_pendingFixedSectionBatch is { } pending &&
+ (GetActiveFixedMarket() != pending.Market ||
+ !pending.IsCurrentManual(
+ screen.Value,
+ _manualListsWorkflow.Current.Audience)))
+ {
+ return ReportError(
+ "고정 컷 섹션의 현재 수동 입력 단계와 열린 입력창이 일치하지 않아 저장하지 않았습니다.");
+ }
+
+ if (screen == LegacyManualListScreen.NetSell)
+ {
+ await _manualListsWorkflow.SaveNetSellAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ await _manualListsWorkflow.SaveViAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ var saved = _manualListsWorkflow.Current;
+ if (!saved.CanMaterializePlaylist)
+ {
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ try
+ {
+ var draft = screen == LegacyManualListScreen.NetSell
+ ? _manualListsWorkflow.CreateNetSellPlaylistDraft()
+ : _manualListsWorkflow.CreateViPlaylistDraft();
+
+ if (_pendingFixedSectionBatch is not null)
+ {
+ _pendingFixedSectionBatch.StageCurrentManual(draft);
+ _manualListsWorkflow.Close();
+ return await ContinuePendingFixedSectionAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ AppendManualListRow(draft);
+ _manualListsWorkflow.Close();
+ _statusMessage = screen == LegacyManualListScreen.NetSell
+ ? "수동 순매도 목록을 저장하고 편성표에 추가했습니다."
+ : "VI 발동 목록을 저장하고 편성표에 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task ImportLegacyManualListsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("원본 수동 데이터 가져오기 기능을 사용할 수 없습니다.");
+ }
+
+ await _manualListsWorkflow.ImportAsync(cancellationToken).ConfigureAwait(false);
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateFixedAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
+ }
+
+ var market = GetActiveFixedMarket();
+ if (market is null)
+ {
+ return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
+ }
+
+ try
+ {
+ var materialized = _fixedCatalog.Materialize(
+ actionId,
+ _timeProvider.GetLocalNow(),
+ _movingAverages);
+ if (materialized.Action.Market != market.Value)
+ {
+ return ReportError("현재 탭에 속하지 않는 항목입니다.");
+ }
+
+ AppendFixedRow(materialized);
+ _statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("등록되지 않은 고정 컷 항목입니다.");
+ }
+ }
+
+ public async Task ActivateFixedActionAsync(
+ string actionId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
+ }
+
+ var manualTarget = GetFixedManualTarget(actionId);
+
+ if (manualTarget is null)
+ {
+ return ActivateFixedAction(actionId);
+ }
+
+ var market = GetActiveFixedMarket();
+ if (market != LegacyFixedMarket.Index)
+ {
+ return ReportError("현재 탭에 속하지 않는 수동 입력 항목입니다.");
+ }
+
+ return manualTarget.Value.Screen == LegacyManualListScreen.NetSell
+ ? await OpenManualNetSellAsync(
+ manualTarget.Value.Audience,
+ cancellationToken).ConfigureAwait(false)
+ : await OpenManualViAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Preserves UC1 parent-node direct-child order while closing its manual-child gap:
+ /// the original parent path removed FSell rows and returned at VI, because the
+ /// ShowDialog calls lived only in the leaf path. Automatic children are materialized
+ /// into a native staging area first; FSell/VI children are opened one at a time and
+ /// the playlist is committed only after every manual child has been confirmed.
+ ///
+ public async Task ActivateFixedSection(
+ int sectionIndex,
+ CancellationToken cancellationToken = default)
+ {
+ var market = GetActiveFixedMarket();
+ if (market is null)
+ {
+ return ReportError("현재 탭에서는 고정 컷 섹션을 선택할 수 없습니다.");
+ }
+
+ var sections = _fixedCatalog.GetSections(market.Value);
+ if (sectionIndex < 0 || sectionIndex >= sections.Count)
+ {
+ return ReportError("등록되지 않은 고정 컷 섹션입니다.");
+ }
+
+ if (_pendingFixedSectionBatch is not null)
+ {
+ return ReportError(
+ "이미 고정 컷 섹션의 수동 입력을 진행하고 있습니다. 먼저 완료하거나 취소하세요.");
+ }
+
+ if (_manualListsWorkflow?.Current.IsOpen == true)
+ {
+ return ReportError("열려 있는 수동 입력창을 먼저 완료하거나 닫으세요.");
+ }
+
+ var section = sections[sectionIndex];
+ try
+ {
+ var localNow = _timeProvider.GetLocalNow();
+ var items = new List(section.Actions.Count);
+ foreach (var action in section.Actions)
+ {
+ var manualTarget = GetFixedManualTarget(action.Id);
+ if (manualTarget is not null)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError(
+ "이 섹션에는 수동 입력 항목이 있지만 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ items.Add(new PendingFixedSectionItem(action, manualTarget.Value));
+ continue;
+ }
+
+ // Every non-manual child is validated before the first playlist row
+ // is appended or the first modal editor is opened.
+ items.Add(new PendingFixedSectionItem(
+ action,
+ _fixedCatalog.Materialize(
+ action.Id,
+ localNow,
+ _movingAverages)));
+ }
+
+ var manualCount = items.Count(item => item.ManualTarget is not null);
+ if (manualCount == 0)
+ {
+ CommitFixedSectionBatch(items);
+ _statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ _pendingFixedSectionBatch = new PendingFixedSectionBatch(
+ market.Value,
+ sectionIndex,
+ section.Name,
+ items,
+ manualCount);
+ return await OpenPendingFixedSectionManualAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (InvalidOperationException)
+ {
+ _pendingFixedSectionBatch = null;
+ return ReportError("섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot SelectIndustry(int index, bool doubleClick)
+ {
+ if (!IsActiveIndustryReady())
+ {
+ return ReportError("업종 목록을 먼저 불러오세요.");
+ }
+
+ if (doubleClick)
+ {
+ _industryWorkflow!.DoubleClickIndustry(index);
+ }
+ else
+ {
+ _industryWorkflow!.SelectIndustry(index);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SwapIndustries()
+ {
+ if (!IsActiveIndustryReady())
+ {
+ return ReportError("업종 목록을 먼저 불러오세요.");
+ }
+
+ _industryWorkflow!.SwapIndustries();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateIndustryAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsActiveIndustryReady())
+ {
+ return ReportError("업종 목록을 먼저 불러오세요.");
+ }
+
+ try
+ {
+ var draft = _industryWorkflow!.MaterializePlaylistDraft(actionId);
+ AppendIndustryRow(draft, _industryWorkflow.Current.Industries.Count);
+ _statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (LegacyIndustryWorkflowException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot ActivateIndustrySection(int sectionIndex)
+ {
+ if (!IsActiveIndustryReady())
+ {
+ return ReportError("업종 목록을 먼저 불러오세요.");
+ }
+
+ var snapshot = _industryWorkflow!.Current;
+ var sections = snapshot.Actions
+ .Select(static action => action.Section)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (sectionIndex < 0 || sectionIndex >= sections.Length)
+ {
+ return ReportError("등록되지 않은 업종 컷 섹션입니다.");
+ }
+
+ try
+ {
+ var section = sections[sectionIndex];
+ // Materialize every direct child before mutating the playlist. This keeps
+ // the original parent double-click all-or-nothing when one child lacks a
+ // required industry selection.
+ var drafts = snapshot.Actions
+ .Where(action => string.Equals(
+ action.Section,
+ section,
+ StringComparison.Ordinal))
+ .Select(action => _industryWorkflow.MaterializePlaylistDraft(action.ActionId))
+ .ToArray();
+ foreach (var draft in drafts)
+ {
+ AppendIndustryRow(draft, snapshot.Industries.Count);
+ }
+
+ _statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (LegacyIndustryWorkflowException exception)
+ {
+ _dialog = new LegacyDialogState(
+ $"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot SelectOverseasFixedIndex(string targetId)
+ {
+ ArgumentNullException.ThrowIfNull(targetId);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var selected = _overseasWorkflow.SelectFixedIndex(targetId).SelectedFixedIndex!;
+ _statusMessage = $"{selected.DisplayName} 지수를 선택했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("등록되지 않은 해외지수입니다.");
+ }
+ }
+
+ public async Task SearchOverseasIndustriesAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ var overseas = await _overseasWorkflow.SearchIndustriesAsync(
+ query,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ _statusMessage = $"해외업종 {overseas.Industry.ResultCount}건을 찾았습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchOverseasStocksAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ var overseas = await _overseasWorkflow.SearchStocksAsync(
+ query,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ _statusMessage = $"해외종목 {overseas.Stock.ResultCount}건을 찾았습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectOverseasIndustry(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var selected = _overseasWorkflow.SelectIndustry(selectionId).Industry.Selected!;
+ _statusMessage = $"{selected.KoreanName} 업종을 선택했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("현재 검색 결과에 없는 해외업종입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot SelectOverseasStock(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var selected = _overseasWorkflow.SelectStock(selectionId).Stock.Selected!;
+ _statusMessage = $"{selected.InputName} 종목을 선택했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("현재 검색 결과에 없는 해외종목입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot ActivateOverseasAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsOverseasActive() || _overseasWorkflow is null)
+ {
+ return ReportError("해외종목 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var draft = _overseasWorkflow.MaterializePlaylistDraft(actionId);
+ AppendOverseasRow(draft);
+ _statusMessage = $"{draft.Title} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (LegacyOverseasWorkflowException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task SearchComparisonDomesticAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ await _comparisonWorkflow.SearchDomesticAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SearchComparisonWorldAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ await _comparisonWorkflow.SearchWorldAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectComparisonDomesticResult(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("Select the comparison tab first.");
+ }
+
+ _comparisonWorkflow.SelectDomesticResult(selectionId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectComparisonWorldResult(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("Select the comparison tab first.");
+ }
+
+ _comparisonWorkflow.SelectWorldResult(selectionId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectComparisonFixedTarget(string targetId)
+ {
+ ArgumentNullException.ThrowIfNull(targetId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("Select the comparison tab first.");
+ }
+
+ _comparisonWorkflow.SelectFixedTarget(targetId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ChooseComparisonDomesticResult(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.ChooseDomesticResult(selectionId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ChooseComparisonWorldResult(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.ChooseWorldResult(selectionId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ChooseComparisonFixedTarget(string targetId)
+ {
+ ArgumentNullException.ThrowIfNull(targetId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.ChooseFixedTarget(targetId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SwapComparisonTargets()
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.SwapDraftTargets();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ClearComparisonTargets()
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.ClearDraftTargets();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task AddComparisonPairAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ await _comparisonWorkflow.AddDraftPairAsync(cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot SelectComparisonPair(string rowId)
+ {
+ ArgumentNullException.ThrowIfNull(rowId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ _comparisonWorkflow.SelectSavedPair(rowId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task MoveComparisonPairAsync(
+ LegacyComparisonPairMoveDirection direction,
+ CancellationToken cancellationToken = default)
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ await _comparisonWorkflow.MoveSelectedPairAsync(direction, cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task DeleteComparisonPairAsync(
+ bool all,
+ CancellationToken cancellationToken = default)
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ if (all)
+ {
+ await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ await _comparisonWorkflow.DeleteSelectedPairAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateComparisonAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var draft = _comparisonWorkflow.MaterializeSelectedAction(actionId);
+ AppendComparisonRow(draft);
+ _statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 비교 컷 항목입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot ActivateComparisonSection(int sectionIndex)
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 탭을 먼저 선택하세요.");
+ }
+
+ var snapshot = _comparisonWorkflow.Current;
+ var sections = snapshot.Actions
+ .Select(static action => action.Section)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (sectionIndex < 0 || sectionIndex >= sections.Length)
+ {
+ return ReportError("등록되지 않은 비교 컷 섹션입니다.");
+ }
+
+ var section = sections[sectionIndex];
+ var selectedPair = snapshot.SavedPairs.FirstOrDefault(static pair => pair.IsSelected);
+ if (string.Equals(section, "종목별 수익률 비교", StringComparison.Ordinal) &&
+ selectedPair is not null &&
+ (selectedPair.First.Kind == LegacyComparisonTargetKind.NxtStock ||
+ selectedPair.Second.Kind == LegacyComparisonTargetKind.NxtStock))
+ {
+ _dialog = new LegacyDialogState(
+ "NXT 종목이 포함된 비교쌍에는 종목별 수익률 비교 섹션을 추가할 수 없습니다.");
+ Touch();
+ return CreateSnapshot();
+ }
+
+ var actions = snapshot.Actions
+ .Where(action => string.Equals(
+ action.Section,
+ section,
+ StringComparison.Ordinal))
+ .ToArray();
+ if (actions.Length == 0 || actions.Any(static action => !action.IsAvailable))
+ {
+ _dialog = new LegacyDialogState(
+ "섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
+ Touch();
+ return CreateSnapshot();
+ }
+
+ try
+ {
+ var drafts = actions
+ .Select(action => _comparisonWorkflow.MaterializeSelectedAction(action.ActionId))
+ .ToArray();
+ foreach (var draft in drafts)
+ {
+ AppendComparisonRow(draft);
+ }
+
+ _statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(
+ $"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 비교 컷 항목입니다.");
+ }
+ }
+
+ public async Task SearchThemesAsync(
+ string query,
+ MMoneyCoderSharp.Data.ThemeSession nxtSession,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
+ {
+ return ReportError("테마 탭을 먼저 선택하세요.");
+ }
+
+ _themeSnapshot = await _themeWorkflow.SearchAsync(
+ _themeSnapshot,
+ query,
+ nxtSession,
+ cancellationToken).ConfigureAwait(false);
+ _themeActivationListLoaded = true;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SelectThemeAsync(
+ string resultId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(resultId);
+ if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
+ {
+ return ReportError("테마 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ _themeSnapshot = await _themeWorkflow.SelectAsync(
+ _themeSnapshot,
+ resultId,
+ cancellationToken).ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("등록되지 않은 테마 검색 결과입니다.");
+ }
+ }
+
+ public async Task ActivateThemeResultAsync(
+ string resultId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(resultId);
+ const string command = "activate-theme-result";
+ const string originalDoubleClickAction = "five-row-current";
+ if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
+ {
+ CompleteCommand(command, resultId, succeeded: false);
+ return ReportError("Select the theme tab before activating a result.");
+ }
+
+ try
+ {
+ // The original row double-click first selected the row, then emitted the
+ // fixed five-row current-price cut. Await preview loading so those stages
+ // cannot race each other.
+ _themeSnapshot = await _themeWorkflow.SelectAsync(
+ _themeSnapshot,
+ resultId,
+ cancellationToken).ConfigureAwait(false);
+ var draft = _themeWorkflow.MaterializePlaylistDraft(
+ _themeSnapshot,
+ originalDoubleClickAction);
+ AppendThemeRow(draft);
+ _statusMessage = $"{draft.Detail} item was added.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ CompleteCommand(command, resultId, succeeded: true);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException or KeyNotFoundException or
+ LegacyThemeWorkflowDataException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ _statusMessage = exception.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ CompleteCommand(command, resultId, succeeded: false);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOperatorSnapshot SetThemeSort(string sortId)
+ {
+ ArgumentNullException.ThrowIfNull(sortId);
+ if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
+ {
+ return ReportError("테마 탭을 먼저 선택하세요.");
+ }
+
+ _themeSnapshot = _themeWorkflow.SelectSort(_themeSnapshot, sortId);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateThemeAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
+ {
+ return ReportError("테마 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var draft = _themeWorkflow.MaterializePlaylistDraft(_themeSnapshot, actionId);
+ AppendThemeRow(draft);
+ _statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("등록되지 않은 테마 컷 항목입니다.");
+ }
+ }
+
+ public async Task SearchExpertsAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsExpertActive() || _expertWorkflow is null)
+ {
+ return ReportError("전문가 탭을 먼저 선택하세요.");
+ }
+
+ await _expertWorkflow.SearchAsync(query, cancellationToken).ConfigureAwait(false);
+ _expertActivationListLoaded = true;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SelectExpertAsync(
+ string selectionId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsExpertActive() || _expertWorkflow is null)
+ {
+ return ReportError("전문가 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ await _expertWorkflow.SelectExpertAsync(selectionId, cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 전문가 검색 결과입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot ActivateExpertAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsExpertActive() || _expertWorkflow is null)
+ {
+ return ReportError("전문가 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var draft = _expertWorkflow.MaterializeSelectedAction(actionId);
+ AppendExpertRow(draft);
+ _statusMessage = $"{draft.Title} 추천 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 전문가 컷 항목입니다.");
+ }
+ }
+
+ public async Task SearchTradingHaltsAsync(
+ string query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
+ {
+ return ReportError("정지 탭을 먼저 선택하세요.");
+ }
+
+ await _tradingHaltWorkflow.SearchAsync(query, cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectTradingHalt(string selectionId)
+ {
+ ArgumentNullException.ThrowIfNull(selectionId);
+ if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
+ {
+ return ReportError("정지 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ _tradingHaltWorkflow.SelectResult(selectionId);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 거래정지 검색 결과입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot ToggleTradingHaltSort()
+ {
+ if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
+ {
+ return ReportError("정지 탭을 먼저 선택하세요.");
+ }
+
+ _tradingHaltWorkflow.ToggleNameSort();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ActivateTradingHaltAction(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
+ {
+ return ReportError("정지 탭을 먼저 선택하세요.");
+ }
+
+ try
+ {
+ var draft = _tradingHaltWorkflow.MaterializeSelectedAction(actionId);
+ AppendTradingHaltRow(draft);
+ _statusMessage = $"{draft.Title} 항목을 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return ReportError("등록되지 않은 거래정지 컷 항목입니다.");
+ }
+ }
+
+ public LegacyOperatorSnapshot SelectPlaylistRow(
+ string rowId,
+ bool control,
+ bool shift)
+ {
+ ArgumentNullException.ThrowIfNull(rowId);
+ var index = FindPlaylistIndex(rowId);
+ if (index < 0)
+ {
+ return CreateSnapshot();
+ }
+
+ _activePlaylistRowId = rowId;
+
+ // MainForm handled Control before Shift. Consequently Ctrl+Shift added only
+ // the clicked row, and clicking an already selected row did not remove it.
+ if (control)
+ {
+ _selectedPlaylistRowIds.Add(rowId);
+ }
+ else
+ {
+ if (_playlistCursorIndex != index)
+ {
+ _playlistPriorCursorIndex = _playlistCursorIndex;
+ _playlistCursorIndex = index;
+ }
+
+ _selectedPlaylistRowIds.Clear();
+ if (shift)
+ {
+ AddPlaylistRange(_playlistPriorCursorIndex, _playlistCursorIndex);
+ }
+ else
+ {
+ _selectedPlaylistRowIds.Add(rowId);
+ }
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled()
+ {
+ return _activePlaylistRowId is null
+ ? CreateSnapshot()
+ : TogglePlaylistRowEnabled(_activePlaylistRowId);
+ }
+
+ public LegacyOperatorSnapshot SelectPlaylistBoundary(bool last)
+ {
+ if (_playlist.Count == 0)
+ {
+ return CreateSnapshot();
+ }
+
+ var index = last ? _playlist.Count - 1 : 0;
+ var rowId = _playlist[index].RowId;
+ _selectedPlaylistRowIds.Clear();
+ _selectedPlaylistRowIds.Add(rowId);
+ _activePlaylistRowId = rowId;
+ _playlistPriorCursorIndex = index;
+ _playlistCursorIndex = index;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot DeleteSelectedPlaylistRows()
+ {
+ if (_selectedPlaylistRowIds.Count == 0)
+ {
+ return CreateSnapshot();
+ }
+
+ _playlist.RemoveAll(row => _selectedPlaylistRowIds.Contains(row.RowId));
+ ResetPlaylistSelection();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot DeleteAllPlaylistRows()
+ {
+ if (_playlist.Count == 0)
+ {
+ return CreateSnapshot();
+ }
+
+ _playlist.Clear();
+ ResetPlaylistSelection();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot MoveSelectedPlaylistRows(
+ LegacyPlaylistMoveDirection direction)
+ {
+ var activeIndex = _activePlaylistRowId is null
+ ? -1
+ : FindPlaylistIndex(_activePlaylistRowId);
+ if (activeIndex < 0)
+ {
+ return CreateSnapshot();
+ }
+
+ var targetIndex = direction switch
+ {
+ LegacyPlaylistMoveDirection.Up => activeIndex - 1,
+ LegacyPlaylistMoveDirection.Down => activeIndex + 1,
+ _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null)
+ };
+ if (targetIndex < 0 || targetIndex >= _playlist.Count)
+ {
+ return CreateSnapshot();
+ }
+
+ // Spd_UpDown exchanged all seven cell values for the active row only. It did
+ // not move an entire multi-selection as a block.
+ (_playlist[activeIndex], _playlist[targetIndex]) =
+ (_playlist[targetIndex], _playlist[activeIndex]);
+
+ _selectedPlaylistRowIds.Clear();
+ _selectedPlaylistRowIds.Add(_activePlaylistRowId!);
+ _playlistPriorCursorIndex = targetIndex;
+ _playlistCursorIndex = targetIndex;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task RefreshNamedPlaylistsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ var namedPlaylist = await _namedPlaylistWorkflow.RefreshAsync(
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ ApplyNamedPlaylistStatus(namedPlaylist);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ ApplyNamedPlaylistStatus(
+ _namedPlaylistWorkflow.Current,
+ "DB 재생목록 목록을 불러오지 못했습니다.",
+ LegacyOperatorStatusKind.Error);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot SelectNamedPlaylist(string definitionId)
+ {
+ ArgumentNullException.ThrowIfNull(definitionId);
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ ApplyNamedPlaylistStatus(
+ _namedPlaylistWorkflow.SelectDefinition(definitionId));
+ }
+ catch (Exception exception) when (exception is KeyNotFoundException or
+ InvalidOperationException or ArgumentException)
+ {
+ _dialog = new LegacyDialogState(
+ "선택한 DB 재생목록이 더 이상 유효하지 않습니다. 목록을 새로 고치세요.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task LoadSelectedNamedPlaylistAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
+ cancellationToken).ConfigureAwait(false);
+ var loadedRows = namedPlaylist.Rows
+ .Select(CreateNamedPlaylistOperatorRow)
+ .ToArray();
+
+ _playlist.Clear();
+ _playlist.AddRange(loadedRows);
+ ResetPlaylistSelection();
+ ApplyNamedPlaylistStatus(namedPlaylist);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidDataException)
+ {
+ _dialog = new LegacyDialogState(
+ "DB 재생목록에 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Error;
+ }
+ catch (Exception exception) when (exception is KeyNotFoundException or
+ InvalidOperationException or ArgumentException)
+ {
+ _dialog = new LegacyDialogState("불러올 DB 재생목록을 먼저 선택하세요.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+ catch
+ {
+ ApplyNamedPlaylistStatus(
+ _namedPlaylistWorkflow.Current,
+ "선택한 DB 재생목록을 불러오지 못했습니다.",
+ LegacyOperatorStatusKind.Error);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task LoadNamedPlaylistByIdAsync(
+ string definitionId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(definitionId);
+ const string command = "load-named-playlist-by-id";
+ var selected = SelectNamedPlaylist(definitionId);
+ if (!string.Equals(
+ selected.NamedPlaylist?.SelectedDefinitionId,
+ definitionId,
+ StringComparison.Ordinal))
+ {
+ CompleteCommand(command, definitionId, succeeded: false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ var loaded = await LoadSelectedNamedPlaylistAsync(cancellationToken)
+ .ConfigureAwait(false);
+ var succeeded =
+ string.Equals(
+ loaded.NamedPlaylist?.SelectedDefinitionId,
+ definitionId,
+ StringComparison.Ordinal) &&
+ loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready &&
+ loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh &&
+ loaded.StatusKind == LegacyOperatorStatusKind.Information;
+ CompleteCommand(command, definitionId, succeeded);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task CreateNamedPlaylistAsync(
+ string title,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(title);
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ var draft = _namedPlaylistWorkflow.CreateProgramDraft(title);
+ var result = await _namedPlaylistWorkflow.CreateProgramAsync(
+ draft,
+ cancellationToken).ConfigureAwait(false);
+ ApplyNamedPlaylistStatus(result.Snapshot);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(
+ "새 DB 재생목록을 만들 수 없습니다. 목록을 새로 고친 뒤 이름을 확인하세요.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SaveCurrentNamedPlaylistAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ if (_playlist.Count > LegacyNamedPlaylistWorkflowController.MaximumItems)
+ {
+ throw new InvalidOperationException("The playlist is too large to save.");
+ }
+
+ // DC_LIST/PLAY_LIST compatibility is exactly the original seven visible/hidden
+ // fields. Do not persist the newer internal scene selection or scene alias.
+ var rows = _playlist.Select(row =>
+ {
+ var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ row.MarketText,
+ row.StockName,
+ row.GraphicType,
+ row.Subtype,
+ row.StockCode);
+ var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry(
+ row.RowId,
+ row.SceneAlias,
+ row.IsEnabled,
+ row.FadeDuration,
+ persistedSelection);
+ return _namedPlaylistWorkflow.CreateRowDraft(
+ entry,
+ ParseNamedPlaylistPage(row.PageText));
+ }).ToArray();
+
+ var result = await _namedPlaylistWorkflow.SaveCurrentAsync(
+ rows,
+ cancellationToken).ConfigureAwait(false);
+ ApplyNamedPlaylistStatus(result.Snapshot);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException or FormatException or OverflowException)
+ {
+ _dialog = new LegacyDialogState(
+ "현재 송출 목록을 저장할 수 없습니다. DB 재생목록 선택과 Page 값을 확인하세요.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task SaveCurrentNamedPlaylistToAsync(
+ string definitionId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(definitionId);
+ const string command = "save-current-named-playlist-to";
+ var selected = SelectNamedPlaylist(definitionId);
+ if (!string.Equals(
+ selected.NamedPlaylist?.SelectedDefinitionId,
+ definitionId,
+ StringComparison.Ordinal))
+ {
+ CompleteCommand(command, definitionId, succeeded: false);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken)
+ .ConfigureAwait(false);
+ var succeeded =
+ string.Equals(
+ saved.NamedPlaylist?.SelectedDefinitionId,
+ definitionId,
+ StringComparison.Ordinal) &&
+ saved.NamedPlaylist?.LastMutationOutcome is
+ LegacyNamedPlaylistMutationOutcome.CommittedFresh or
+ LegacyNamedPlaylistMutationOutcome.CommittedOptimistic &&
+ saved.StatusKind == LegacyOperatorStatusKind.Information;
+ CompleteCommand(command, definitionId, succeeded);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public async Task DeleteSelectedNamedPlaylistAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_namedPlaylistWorkflow is null)
+ {
+ return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
+ }
+
+ try
+ {
+ var result = await _namedPlaylistWorkflow.DeleteSelectedAsync(
+ cancellationToken).ConfigureAwait(false);
+ ApplyNamedPlaylistStatus(result.Snapshot);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is ArgumentException or
+ InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState("삭제할 DB 재생목록을 먼저 선택하세요.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
public LegacyOperatorSnapshot DismissDialog()
{
if (_dialog is not null)
@@ -201,6 +3084,14 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public LegacyOperatorSnapshot ReportInformation(string message)
+ {
+ _statusMessage = message ?? string.Empty;
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
@@ -338,6 +3229,206 @@ public sealed class LegacyOperatorController
}
}
+ private void ApplyNamedPlaylistStatus(
+ LegacyNamedPlaylistWorkflowSnapshot snapshot,
+ string? fallbackMessage = null,
+ LegacyOperatorStatusKind? fallbackKind = null)
+ {
+ _statusMessage = string.IsNullOrWhiteSpace(snapshot.StatusMessage)
+ ? fallbackMessage ?? string.Empty
+ : snapshot.StatusMessage;
+ _statusKind = fallbackKind ?? snapshot.StatusKind;
+ }
+
+ private static MMoneyCoderSharp.Data.NamedPlaylistPageState? ParseNamedPlaylistPage(
+ string pageText)
+ {
+ ArgumentNullException.ThrowIfNull(pageText);
+ if (pageText.Length == 0)
+ {
+ return null;
+ }
+
+ if (!string.Equals(pageText, pageText.Trim(), StringComparison.Ordinal))
+ {
+ throw new FormatException("A named-playlist page must be canonical.");
+ }
+
+ var parts = pageText.Split('/');
+ if (parts.Length != 2 ||
+ !int.TryParse(
+ parts[0],
+ System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var currentPage) ||
+ !int.TryParse(
+ parts[1],
+ System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var totalPages))
+ {
+ throw new FormatException("A named-playlist page must use current/total form.");
+ }
+
+ return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages);
+ }
+
+ private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow(
+ LegacyNamedPlaylistRowView row)
+ {
+ var selection = row.Selection;
+ var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
+ var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ selection.DataCode);
+ var rawCutLabel = selection.Subtype.Length == 0
+ ? selection.GraphicType
+ : $"{selection.GraphicType}_{selection.Subtype}";
+ return new LegacyOperatorPlaylistRow(
+ $"legacy-named-{++_playlistSequence:D8}",
+ row.IsEnabled,
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ row.Page?.PageText ?? string.Empty,
+ selection.DataCode,
+ rawCutLabel,
+ 6,
+ SceneAlias: sceneAlias,
+ PlayoutSelection: playoutSelection);
+ }
+
+ private string ResolveNamedPlaylistSceneAlias(
+ LegacyNamedPlaylistSelectionView selection)
+ {
+ var rawLabel = selection.Subtype.Length == 0
+ ? selection.GraphicType
+ : $"{selection.GraphicType}_{selection.Subtype}";
+
+ // Stock and trading-halt rows retain the original stock-market and cut-label
+ // columns, so the closed stock catalog remains the authority for their alias.
+ if (selection.GroupCode is "코스피" or "코스닥" or
+ "코스피_NXT" or "코스닥_NXT")
+ {
+ try
+ {
+ return LegacyStockCutCatalog.ResolveCutAlias(
+ rawLabel,
+ selection.GroupCode.EndsWith("_NXT", StringComparison.Ordinal) ||
+ selection.Subject.Contains("(NXT)", StringComparison.Ordinal));
+ }
+ catch (InvalidOperationException)
+ {
+ // Continue through the other closed registries. This also permits
+ // manual-financial rows whose market column resembles a stock market.
+ }
+ }
+
+ var fixedMarket = selection.GroupCode switch
+ {
+ "해외지수" => LegacyFixedMarket.Overseas,
+ "환율" => LegacyFixedMarket.Exchange,
+ "주요지수" => LegacyFixedMarket.Index,
+ _ => (LegacyFixedMarket?)null
+ };
+ if (fixedMarket is { } market)
+ {
+ var actions = _fixedCatalog.GetActions(market)
+ .Where(action =>
+ string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
+ string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
+ string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal))
+ .Take(2)
+ .ToArray();
+ if (actions.Length == 1)
+ {
+ return actions[0].Code;
+ }
+ }
+
+ var industryMarket = selection.GroupCode switch
+ {
+ "업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
+ "업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
+ _ => (MMoneyCoderSharp.Data.IndustryMarket?)null
+ };
+ if (industryMarket is { } industry)
+ {
+ var actions = LegacyIndustryActionCatalog.GetActions(industry)
+ .Where(action => string.Equals(
+ action.Label,
+ rawLabel,
+ StringComparison.Ordinal))
+ .Take(2)
+ .ToArray();
+ if (actions.Length == 1)
+ {
+ return actions[0].SceneAlias;
+ }
+ }
+
+ if (selection.GroupCode is "테마" or "테마_NXT")
+ {
+ var detail = selection.Subtype.Length == 0
+ ? selection.GraphicType
+ : $"{selection.GraphicType} · {selection.Subtype}";
+ var actions = LegacyThemeWorkflow.Actions
+ .Where(action => LegacyThemeWorkflow.Sorts.Any(sort => string.Equals(
+ $"{action.Label} · {sort.Label}",
+ detail,
+ StringComparison.Ordinal)))
+ .Take(2)
+ .ToArray();
+ if (actions.Length == 1)
+ {
+ return actions[0].Code;
+ }
+ }
+
+ if (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) &&
+ string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
+ string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal))
+ {
+ return "5074";
+ }
+
+ if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
+ {
+ var alias = selection.GraphicType switch
+ {
+ "s5032" => "5032",
+ "s8018" => "8032",
+ "s5029" => "5029",
+ "s5026" => "5026",
+ "s5087" => "5087",
+ _ => null
+ };
+ if (alias is not null)
+ {
+ return alias;
+ }
+ }
+
+ var manualFinancial = LegacyManualFinancialWorkflow.Screens
+ .Where(definition =>
+ string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
+ (selection.GroupCode is "KOSPI" or "KOSDAQ" or
+ "NXT_KOSPI" or "NXT_KOSDAQ"))
+ .Take(2)
+ .ToArray();
+ if (manualFinancial.Length == 1)
+ {
+ return manualFinancial[0].Code;
+ }
+
+ throw new InvalidDataException(
+ "The named-playlist row does not map to a registered scene alias.");
+ }
+
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames
@@ -352,20 +3443,663 @@ public sealed class LegacyOperatorController
_nativeSelectedCutRows.Contains(row.PhysicalIndex)))
.ToArray();
+ var playlistRows = _playlist
+ .Select(row => row with
+ {
+ IsSelected = _selectedPlaylistRowIds.Contains(row.RowId)
+ })
+ .ToArray();
+ var tabs = _tabOrder
+ .Select(tab => new LegacyOperatorTabState(
+ tab,
+ GetTabLabel(tab),
+ tab == _activeTab))
+ .ToArray();
+ var fixedCatalog = CreateFixedCatalogView();
+ var operatorCatalog = _operatorCatalogWorkflow?.Current;
+
return new LegacyOperatorSnapshot(
_revision,
_searchText,
Array.AsReadOnly(searchRows),
_selectedStockIndex,
Array.AsReadOnly(cutRows),
- Array.AsReadOnly(_playlist.ToArray()),
+ Array.AsReadOnly(playlistRows),
_dialog,
_statusMessage,
- _statusKind);
+ _statusKind,
+ Array.AsReadOnly(tabs),
+ _movingAverages,
+ fixedCatalog,
+ GetActiveIndustrySnapshot(),
+ IsThemeActive() ? _themeSnapshot : null,
+ IsExpertActive() ? _expertWorkflow?.Current : null,
+ IsTradingHaltActive() ? _tradingHaltWorkflow?.Current : null,
+ IsComparisonActive() ? _comparisonWorkflow?.Current : null,
+ _manualFinancialSnapshot,
+ _namedPlaylistWorkflow?.Current,
+ IsOverseasActive() ? _overseasWorkflow?.Current : null,
+ _manualListsWorkflow?.Current,
+ operatorCatalog?.IsOpen == true ? operatorCatalog : null,
+ _commandResult,
+ _pendingFixedSectionBatch?.CreateView());
}
+ private LegacyFixedCatalogView? CreateFixedCatalogView()
+ {
+ var market = GetActiveFixedMarket();
+ if (market is null)
+ {
+ return null;
+ }
+
+ var sections = _fixedCatalog.GetSections(market.Value)
+ .Select((section, index) => new LegacyFixedSectionView(
+ index,
+ section.Name,
+ Array.AsReadOnly(section.Actions
+ .Select(action => new LegacyFixedActionView(
+ action.Id,
+ action.Label,
+ action.Detail,
+ action.Available,
+ action.Prerequisite))
+ .ToArray())))
+ .ToArray();
+ return new LegacyFixedCatalogView(market.Value, Array.AsReadOnly(sections));
+ }
+
+ private LegacyFixedMarket? GetActiveFixedMarket() => _activeTab switch
+ {
+ LegacyOperatorTabId.Overseas => LegacyFixedMarket.Overseas,
+ LegacyOperatorTabId.Exchange => LegacyFixedMarket.Exchange,
+ LegacyOperatorTabId.Index => LegacyFixedMarket.Index,
+ _ => null
+ };
+
+ private MMoneyCoderSharp.Data.IndustryMarket? GetActiveIndustryMarket() => _activeTab switch
+ {
+ LegacyOperatorTabId.KospiIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
+ LegacyOperatorTabId.KosdaqIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
+ _ => null
+ };
+
+ private LegacyIndustrySelectionSnapshot? GetActiveIndustrySnapshot() =>
+ GetActiveIndustryMarket() is { } market &&
+ _industryWorkflow?.Current.Market == market
+ ? _industryWorkflow.Current
+ : null;
+
+ private bool IsActiveIndustryReady() => GetActiveIndustrySnapshot() is not null;
+
+ private bool IsThemeActive() => _activeTab == LegacyOperatorTabId.Theme;
+
+ private void BeginTabActivation(LegacyOperatorTabId tabId)
+ {
+ if (tabId == LegacyOperatorTabId.Theme)
+ {
+ _themeActivationListLoaded = false;
+ }
+ else if (tabId == LegacyOperatorTabId.Expert)
+ {
+ _expertActivationListLoaded = false;
+ }
+ }
+
+ private bool IsCurrentTabActivation(
+ LegacyOperatorTabId tabId,
+ long activationRequest) =>
+ _activeTab == tabId &&
+ Volatile.Read(ref _tabActivationRequest) == activationRequest;
+
+ private bool IsComparisonActive() => _activeTab == LegacyOperatorTabId.Comparison;
+
+ private bool IsExpertActive() => _activeTab == LegacyOperatorTabId.Expert;
+
+ private bool IsTradingHaltActive() => _activeTab == LegacyOperatorTabId.TradingHalt;
+
+ private bool IsOverseasActive() => _activeTab == LegacyOperatorTabId.OverseasStocks;
+
+ private bool TryGetManualFinancial(
+ out LegacyManualFinancialWorkflow workflow,
+ out LegacyManualFinancialWorkflowSnapshot snapshot)
+ {
+ workflow = _manualFinancialWorkflow!;
+ snapshot = _manualFinancialSnapshot!;
+ return workflow is not null && snapshot is not null;
+ }
+
+ private void ApplyManualFinancialResultStatus(
+ LegacyManualFinancialWorkflowSnapshot snapshot,
+ string operation)
+ {
+ _statusMessage = snapshot.Status switch
+ {
+ LegacyManualFinancialWorkflowStatus.WriteCommitted =>
+ $"수동 재무 {operation} 작업이 완료되었습니다.",
+ LegacyManualFinancialWorkflowStatus.WriteRejected =>
+ snapshot.LastMessage ?? $"수동 재무 {operation} 작업이 거부되었습니다.",
+ LegacyManualFinancialWorkflowStatus.ReadBackRequired =>
+ snapshot.LastMessage ?? "저장은 완료됐지만 새로 읽기가 필요합니다.",
+ LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
+ snapshot.LastMessage ?? "결과가 불명확하여 수동 재무 쓰기를 차단했습니다.",
+ _ => snapshot.LastMessage ?? string.Empty
+ };
+ _statusKind = snapshot.Status switch
+ {
+ LegacyManualFinancialWorkflowStatus.WriteCommitted =>
+ LegacyOperatorStatusKind.Information,
+ LegacyManualFinancialWorkflowStatus.WriteRejected =>
+ LegacyOperatorStatusKind.Warning,
+ LegacyManualFinancialWorkflowStatus.ReadBackRequired or
+ LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
+ LegacyOperatorStatusKind.Error,
+ _ => LegacyOperatorStatusKind.Neutral
+ };
+ }
+
+ private void ApplyManualListsStatus()
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return;
+ }
+
+ _statusMessage = _manualListsWorkflow.Current.StatusMessage;
+ _statusKind = _manualListsWorkflow.Current.StatusKind;
+ }
+
+ private async Task OpenPendingFixedSectionManualAsync(
+ CancellationToken cancellationToken)
+ {
+ var pending = _pendingFixedSectionBatch
+ ?? throw new InvalidOperationException("A fixed-section batch is not active.");
+ var item = pending.CurrentManualItem
+ ?? throw new InvalidOperationException("The fixed-section batch has no pending manual item.");
+ var target = item.ManualTarget!.Value;
+
+ if (target.Screen == LegacyManualListScreen.NetSell)
+ {
+ await OpenManualNetSellCoreAsync(target.Audience, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var manual = _manualListsWorkflow!.Current;
+ var view = pending.CreateView();
+ var progress =
+ $"{pending.SectionName} 섹션 수동 입력 {view.ManualStepNumber}/{view.ManualStepCount}: {item.Action.Label}. 확인하면 다음 직계 자식으로 계속합니다.";
+ _statusMessage = string.IsNullOrWhiteSpace(manual.StatusMessage)
+ ? progress
+ : $"{progress} {manual.StatusMessage}";
+ _statusKind = manual.StatusKind == LegacyOperatorStatusKind.Neutral
+ ? LegacyOperatorStatusKind.Information
+ : manual.StatusKind;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ private async Task ContinuePendingFixedSectionAsync(
+ CancellationToken cancellationToken)
+ {
+ var pending = _pendingFixedSectionBatch
+ ?? throw new InvalidOperationException("A fixed-section batch is not active.");
+ if (pending.CurrentManualItem is not null)
+ {
+ return await OpenPendingFixedSectionManualAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ try
+ {
+ CommitFixedSectionBatch(pending.Items);
+ }
+ catch
+ {
+ _pendingFixedSectionBatch = null;
+ throw;
+ }
+
+ _pendingFixedSectionBatch = null;
+ _statusMessage =
+ $"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ private void CommitFixedSectionBatch(IReadOnlyList items)
+ {
+ var originalCount = _playlist.Count;
+ var originalSequence = _playlistSequence;
+ try
+ {
+ foreach (var item in items)
+ {
+ if (item.Materialized is not null)
+ {
+ AppendFixedRow(item.Materialized);
+ }
+ else if (item.ManualDraft is not null)
+ {
+ AppendManualListRow(item.ManualDraft);
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ "A fixed-section batch item was not materialized.");
+ }
+ }
+ }
+ catch
+ {
+ if (_playlist.Count > originalCount)
+ {
+ _playlist.RemoveRange(originalCount, _playlist.Count - originalCount);
+ }
+
+ _playlistSequence = originalSequence;
+ throw;
+ }
+ }
+
+ private static FixedManualTarget? GetFixedManualTarget(string actionId) => actionId switch
+ {
+ "fixed-245" => new FixedManualTarget(
+ LegacyManualListScreen.NetSell,
+ S5025ManualAudience.Individual),
+ "fixed-246" => new FixedManualTarget(
+ LegacyManualListScreen.NetSell,
+ S5025ManualAudience.Foreign),
+ "fixed-247" => new FixedManualTarget(
+ LegacyManualListScreen.NetSell,
+ S5025ManualAudience.Institution),
+ "fixed-262" or "fixed-293" or "fixed-320" => new FixedManualTarget(
+ LegacyManualListScreen.Vi,
+ S5025ManualAudience.Individual),
+ _ => null
+ };
+
+ private void ApplyOperatorCatalogStatus()
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return;
+ }
+
+ var snapshot = _operatorCatalogWorkflow.Current;
+ _statusMessage = snapshot.StatusMessage;
+ _statusKind = snapshot.StatusKind switch
+ {
+ LegacyOperatorCatalogStatusKind.Information => LegacyOperatorStatusKind.Information,
+ LegacyOperatorCatalogStatusKind.Warning => LegacyOperatorStatusKind.Warning,
+ LegacyOperatorCatalogStatusKind.Error => LegacyOperatorStatusKind.Error,
+ _ => LegacyOperatorStatusKind.Neutral
+ };
+ }
+
+ private void AppendManualListRow(LegacyManualListPlaylistDraft draft)
+ {
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-manual-list-{++_playlistSequence:D8}",
+ true,
+ draft.MarketText,
+ draft.StockName,
+ draft.GraphicType,
+ draft.Subtype,
+ draft.PageText,
+ draft.Selection.DataCode,
+ draft.RawCutLabel,
+ draft.FadeDuration,
+ SceneAlias: draft.SceneAlias,
+ PlayoutSelection: draft.Selection));
+ }
+
+ private void AppendFixedRow(LegacyMaterializedFixedAction materialized)
+ {
+ var action = materialized.Action;
+ var selection = materialized.Selection;
+ var marketText = action.Market switch
+ {
+ LegacyFixedMarket.Overseas => "해외지수",
+ LegacyFixedMarket.Exchange => "환율",
+ LegacyFixedMarket.Index => "주요지수",
+ _ => throw new ArgumentOutOfRangeException()
+ };
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-fixed-{++_playlistSequence:D8}",
+ true,
+ marketText,
+ action.Label,
+ action.Section,
+ action.Detail,
+ "1/1",
+ selection.DataCode,
+ action.Label,
+ 6,
+ SceneAlias: action.Code,
+ PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ selection.DataCode)));
+ }
+
+ private void AppendIndustryRow(LegacyIndustryPlaylistDraft draft, int itemCount)
+ {
+ var split = draft.Detail.IndexOf('_');
+ var graphicType = split > 0 ? draft.Detail[..split] : draft.Detail;
+ var subtype = split > 0 && split < draft.Detail.Length - 1
+ ? draft.Detail[(split + 1)..]
+ : string.Empty;
+ var pageCount = draft.PageSize > 0
+ ? Math.Clamp(
+ (int)Math.Ceiling(itemCount / (double)draft.PageSize),
+ 1,
+ 20)
+ : 1;
+ var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi
+ ? "업종_코스피"
+ : "업종_코스닥";
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-industry-{++_playlistSequence:D8}",
+ draft.IsEnabled,
+ marketText,
+ draft.Title,
+ graphicType,
+ subtype,
+ $"1/{pageCount}",
+ draft.Selection.DataCode,
+ draft.Detail,
+ draft.FadeDuration,
+ SceneAlias: draft.SceneAlias,
+ PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ draft.Selection.GroupCode,
+ draft.Selection.Subject,
+ draft.Selection.GraphicType,
+ draft.Selection.Subtype,
+ draft.Selection.DataCode)));
+ }
+
+ private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft)
+ {
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-overseas-{++_playlistSequence:D8}",
+ draft.IsEnabled,
+ draft.Selection.GroupCode,
+ draft.Title,
+ draft.Selection.GraphicType,
+ draft.Selection.Subtype,
+ draft.InitialPageText,
+ draft.Selection.DataCode,
+ draft.Detail,
+ draft.FadeDuration,
+ SceneAlias: draft.SceneAlias,
+ PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ draft.Selection.GroupCode,
+ draft.Selection.Subject,
+ draft.Selection.GraphicType,
+ draft.Selection.Subtype,
+ draft.Selection.DataCode)));
+ }
+
+ private void AppendThemeRow(LegacyThemePlaylistDraft draft)
+ {
+ var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal);
+ var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail;
+ var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty;
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-theme-{++_playlistSequence:D8}",
+ true,
+ draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT",
+ draft.Title,
+ graphicType,
+ subtype,
+ draft.PagePlan.InitialPageText,
+ draft.Selection.DataCode,
+ draft.Detail,
+ 6,
+ SceneAlias: draft.Code,
+ PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
+ draft.Selection.GroupCode,
+ draft.Selection.Subject,
+ draft.Selection.GraphicType,
+ draft.Selection.Subtype,
+ draft.Selection.DataCode)));
+ }
+
+ private void AppendComparisonRow(LegacyGenericPlaylistDraft draft)
+ {
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-comparison-{++_playlistSequence:D8}",
+ draft.IsEnabled,
+ "비교",
+ draft.Title,
+ draft.BuilderKey,
+ draft.Detail,
+ "1/1",
+ draft.Selection.DataCode,
+ draft.Source,
+ draft.FadeDuration,
+ SceneAlias: draft.CutCode,
+ PlayoutSelection: draft.Selection));
+ }
+
+ private void AppendExpertRow(LegacyExpertPlaylistDraft draft)
+ {
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-expert-{++_playlistSequence:D8}",
+ draft.IsEnabled,
+ "전문가",
+ draft.Title,
+ "5단 표그래프",
+ "현재가",
+ draft.PageText,
+ draft.ExpertCode,
+ draft.Detail,
+ draft.FadeDuration,
+ SceneAlias: draft.CutCode,
+ PlayoutSelection: draft.Selection));
+ }
+
+ private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft)
+ {
+ var isCandle = draft.CandlePeriodDays.HasValue;
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-halt-{++_playlistSequence:D8}",
+ draft.IsEnabled,
+ draft.Market == "kospi" ? "코스피" : "코스닥",
+ draft.Title,
+ isCandle ? "캔들그래프" : "1열판기본",
+ isCandle ? "거래정지_120일" : "현재가",
+ draft.PageText,
+ draft.StockCode,
+ draft.Detail,
+ draft.FadeDuration,
+ SceneAlias: draft.CutCode,
+ PlayoutSelection: draft.Selection));
+ }
+
+ private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft)
+ {
+ _playlist.Add(new LegacyOperatorPlaylistRow(
+ $"legacy-manual-financial-{++_playlistSequence:D8}",
+ true,
+ draft.Market,
+ draft.Title,
+ draft.Detail,
+ draft.CutSelection.Selection.GraphicType,
+ "1/1",
+ draft.StockCode,
+ draft.Detail,
+ 6,
+ SceneAlias: draft.Code,
+ PlayoutSelection: draft.CutSelection.Selection));
+ }
+
+ private static string GetTabLabel(LegacyOperatorTabId tabId) => tabId switch
+ {
+ LegacyOperatorTabId.Overseas => "해외",
+ LegacyOperatorTabId.Exchange => "환율",
+ LegacyOperatorTabId.Index => "지수",
+ LegacyOperatorTabId.KospiIndustry => "코스피",
+ LegacyOperatorTabId.KosdaqIndustry => "코스닥",
+ LegacyOperatorTabId.Comparison => "비교",
+ LegacyOperatorTabId.Theme => "테마",
+ LegacyOperatorTabId.OverseasStocks => "해외종목",
+ LegacyOperatorTabId.Expert => "전문가",
+ LegacyOperatorTabId.TradingHalt => "정지",
+ _ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null)
+ };
+
private static bool IsCutIndex(int physicalIndex) =>
physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
+ private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row =>
+ string.Equals(row.RowId, rowId, StringComparison.Ordinal));
+
+ private void AddPlaylistRange(int firstIndex, int lastIndex)
+ {
+ var first = Math.Max(0, Math.Min(firstIndex, lastIndex));
+ var last = Math.Min(_playlist.Count - 1, Math.Max(firstIndex, lastIndex));
+ for (var index = first; index <= last; index++)
+ {
+ _selectedPlaylistRowIds.Add(_playlist[index].RowId);
+ }
+ }
+
+ private void ResetPlaylistSelection()
+ {
+ _selectedPlaylistRowIds.Clear();
+ _activePlaylistRowId = null;
+ _playlistCursorIndex = 0;
+ _playlistPriorCursorIndex = 0;
+ }
+
+ private void CompleteCommand(string command, string targetId, bool succeeded)
+ {
+ _commandResult = new LegacyOperatorCommandResult(
+ checked(++_commandSequence),
+ command,
+ targetId,
+ succeeded);
+ }
+
+ private readonly record struct FixedManualTarget(
+ LegacyManualListScreen Screen,
+ S5025ManualAudience Audience);
+
+ private sealed class PendingFixedSectionItem
+ {
+ internal PendingFixedSectionItem(
+ LegacyFixedAction action,
+ LegacyMaterializedFixedAction materialized)
+ {
+ Action = action;
+ Materialized = materialized;
+ }
+
+ internal PendingFixedSectionItem(
+ LegacyFixedAction action,
+ FixedManualTarget manualTarget)
+ {
+ Action = action;
+ ManualTarget = manualTarget;
+ }
+
+ internal LegacyFixedAction Action { get; }
+
+ internal LegacyMaterializedFixedAction? Materialized { get; }
+
+ internal FixedManualTarget? ManualTarget { get; }
+
+ internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; }
+
+ internal bool IsStaged => Materialized is not null || ManualDraft is not null;
+
+ internal void StageManual(LegacyManualListPlaylistDraft draft)
+ {
+ ArgumentNullException.ThrowIfNull(draft);
+ if (ManualTarget is null || ManualDraft is not null)
+ {
+ throw new InvalidOperationException(
+ "The fixed-section manual item cannot be staged in its current state.");
+ }
+
+ ManualDraft = draft;
+ }
+ }
+
+ private sealed class PendingFixedSectionBatch
+ {
+ internal PendingFixedSectionBatch(
+ LegacyFixedMarket market,
+ int sectionIndex,
+ string sectionName,
+ IReadOnlyList items,
+ int manualCount)
+ {
+ Market = market;
+ SectionIndex = sectionIndex;
+ SectionName = sectionName;
+ Items = items;
+ ManualCount = manualCount;
+ }
+
+ internal LegacyFixedMarket Market { get; }
+
+ internal int SectionIndex { get; }
+
+ internal string SectionName { get; }
+
+ internal IReadOnlyList Items { get; }
+
+ internal int ManualCount { get; }
+
+ internal PendingFixedSectionItem? CurrentManualItem => Items.FirstOrDefault(item =>
+ item.ManualTarget is not null && item.ManualDraft is null);
+
+ internal bool IsCurrentManual(
+ LegacyManualListScreen screen,
+ S5025ManualAudience audience)
+ {
+ var target = CurrentManualItem?.ManualTarget;
+ return target is not null && target.Value.Screen == screen &&
+ (screen == LegacyManualListScreen.Vi || target.Value.Audience == audience);
+ }
+
+ internal void StageCurrentManual(LegacyManualListPlaylistDraft draft)
+ {
+ var current = CurrentManualItem
+ ?? throw new InvalidOperationException(
+ "The fixed-section batch has no pending manual item.");
+ current.StageManual(draft);
+ }
+
+ internal LegacyFixedSectionBatchView CreateView()
+ {
+ var current = CurrentManualItem
+ ?? throw new InvalidOperationException(
+ "A completed fixed-section batch cannot be projected as pending.");
+ var manualStep = Items
+ .Where(item => item.ManualTarget is not null)
+ .TakeWhile(item => !ReferenceEquals(item, current))
+ .Count() + 1;
+ return new LegacyFixedSectionBatchView(
+ SectionIndex,
+ SectionName,
+ Items.Count,
+ Items.Count(item => item.IsStaged),
+ manualStep,
+ ManualCount,
+ current.Action.Id,
+ current.Action.Label);
+ }
+ }
+
private void Touch() => _revision++;
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs
new file mode 100644
index 0000000..0ce0109
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs
@@ -0,0 +1,56 @@
+using MBN_STOCK_WEBVIEW.Core.Playout;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyOperatorPlayoutPhase
+{
+ Idle,
+ Prepared,
+ Program
+}
+
+public enum LegacyOperatorPlayoutCommand
+{
+ Prepare,
+ TakeIn,
+ Next,
+ TakeOut
+}
+
+public sealed record LegacyOperatorPlayoutRequest(
+ IReadOnlyList Playlist,
+ int SelectedIndexZeroBased);
+
+public sealed record LegacyOperatorPlayoutSnapshot(
+ PlayoutMode Mode,
+ PlayoutConnectionState ConnectionState,
+ LegacyOperatorPlayoutPhase Phase,
+ bool IsProcessRunning,
+ bool IsConnected,
+ bool IsCommandAvailable,
+ bool LiveTakeInAllowed,
+ bool IsPlayCompletionPending,
+ bool OutcomeUnknown,
+ string? PreparedCode,
+ string? OnAirCode,
+ int CurrentCueIndexZeroBased,
+ string? CurrentEntryId,
+ string? BuilderKey,
+ int PageIndexZeroBased,
+ int PageCount,
+ int PageSize,
+ int ItemCount,
+ int CurrentPageItemCount,
+ bool IsLastPage,
+ LegacyWorkflowNextKind NextKind,
+ bool IsBusy,
+ bool RefreshActive,
+ int RefreshCompletedCount,
+ int? RefreshMaximumCount,
+ bool RefreshLimitReached,
+ int FadeDuration,
+ bool BackgroundEnabled,
+ string BackgroundFileName,
+ bool CanChangeBackground,
+ string Message);
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs
new file mode 100644
index 0000000..a0a903a
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs
@@ -0,0 +1,283 @@
+using System.Collections.ObjectModel;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyOverseasActionSource
+{
+ FixedIndex,
+ Industry,
+ Stock
+}
+
+public enum LegacyOverseasActionKind
+{
+ Current,
+ Candle
+}
+
+public sealed class LegacyOverseasFixedIndexTarget
+{
+ internal LegacyOverseasFixedIndexTarget(
+ string targetId,
+ string displayName,
+ string inputName,
+ string symbol,
+ string nationCode)
+ {
+ TargetId = targetId;
+ DisplayName = displayName;
+ InputName = inputName;
+ Symbol = symbol;
+ NationCode = nationCode;
+ }
+
+ public string TargetId { get; }
+
+ public string DisplayName { get; }
+
+ public string InputName { get; }
+
+ public string Symbol { get; }
+
+ public string NationCode { get; }
+
+ public string ForeignDomesticCode => "0";
+}
+
+public sealed class LegacyOverseasActionDefinition
+{
+ internal LegacyOverseasActionDefinition(
+ string actionId,
+ string label,
+ LegacyOverseasActionSource source,
+ LegacyOverseasActionKind kind,
+ string builderKey,
+ string sceneAlias,
+ int? periodDays)
+ {
+ ActionId = actionId;
+ Label = label;
+ Source = source;
+ Kind = kind;
+ BuilderKey = builderKey;
+ SceneAlias = sceneAlias;
+ Aliases = Array.AsReadOnly([sceneAlias]);
+ PeriodDays = periodDays;
+ }
+
+ public string ActionId { get; }
+
+ public string Label { get; }
+
+ public LegacyOverseasActionSource Source { get; }
+
+ public LegacyOverseasActionKind Kind { get; }
+
+ public string BuilderKey { get; }
+
+ public string SceneAlias { get; }
+
+ public IReadOnlyList Aliases { get; }
+
+ public int PageSize => 0;
+
+ public int? PeriodDays { get; }
+
+ public bool SupportsMovingAverages => Kind == LegacyOverseasActionKind.Candle;
+}
+
+///
+/// Closed UC5 metadata. The original control exposed ten fixed world indices and twelve
+/// buttons: five index candles, one US-industry current plate, and six US/TW stock actions.
+/// Scene identities never come from Web input.
+///
+public static class LegacyOverseasActionCatalog
+{
+ public const int FixedIndexTargetCount = 10;
+ public const int ActionCount = 12;
+
+ private static readonly IReadOnlyList RegisteredTargets =
+ Array.AsReadOnly(
+ [
+ Target("dow-jones", "다우존스", "DJI@DJI", "US"),
+ Target("nasdaq", "나스닥", "NAS@IXIC", "US"),
+ Target("sp500", "S&P500", "SPI@SPX", "US"),
+ Target("germany-dax-30", "독일 DAX 30", "XTR@DAX30", "DE"),
+ Target("united-kingdom-ftse-100", "영국 FTSE 100", "LNS@FTSE100", "GB"),
+ Target("france-cac-40", "프랑스 CAC 40", "PAS@CAC40", "FR"),
+ Target("japan-nikkei", "일본 니케이", "NII@NI225", "JP"),
+ Target("china-shanghai-composite", "중국 상해종합", "SHS@000001", "CN"),
+ Target("hong-kong-hang-seng", "홍콩 항셍", "HSI@HSI", "HK"),
+ Target("taiwan-weighted", "대만 가권", "TWS@TI01", "TW")
+ ]);
+
+ private static readonly IReadOnlyList RegisteredActions =
+ Array.AsReadOnly(CreateActions());
+
+ private static readonly IReadOnlyDictionary
+ TargetsById = new ReadOnlyDictionary(
+ RegisteredTargets.ToDictionary(target => target.TargetId, StringComparer.Ordinal));
+
+ private static readonly IReadOnlyDictionary
+ ActionsById = new ReadOnlyDictionary(
+ RegisteredActions.ToDictionary(action => action.ActionId, StringComparer.Ordinal));
+
+ static LegacyOverseasActionCatalog()
+ {
+ ValidateTargets();
+ ValidateActions();
+ }
+
+ public static IReadOnlyList FixedIndexTargets =>
+ RegisteredTargets;
+
+ public static IReadOnlyList Actions => RegisteredActions;
+
+ public static IReadOnlyList GetActions(
+ LegacyOverseasActionSource source)
+ {
+ if (!Enum.IsDefined(source))
+ {
+ throw new ArgumentOutOfRangeException(nameof(source), source, "Unknown overseas source.");
+ }
+
+ return Array.AsReadOnly(
+ RegisteredActions.Where(action => action.Source == source).ToArray());
+ }
+
+ public static LegacyOverseasFixedIndexTarget? FindFixedIndexTarget(string? targetId) =>
+ targetId is not null && TargetsById.TryGetValue(targetId, out var target)
+ ? target
+ : null;
+
+ public static LegacyOverseasFixedIndexTarget GetFixedIndexTarget(string targetId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
+ return TargetsById.TryGetValue(targetId, out var target)
+ ? target
+ : throw new KeyNotFoundException("The overseas fixed-index target is not registered.");
+ }
+
+ public static LegacyOverseasActionDefinition? FindAction(string? actionId) =>
+ actionId is not null && ActionsById.TryGetValue(actionId, out var action)
+ ? action
+ : null;
+
+ public static LegacyOverseasActionDefinition GetAction(string actionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
+ return ActionsById.TryGetValue(actionId, out var action)
+ ? action
+ : throw new KeyNotFoundException("The overseas action is not registered.");
+ }
+
+ private static LegacyOverseasFixedIndexTarget Target(
+ string targetId,
+ string inputName,
+ string symbol,
+ string nationCode) =>
+ new(targetId, inputName, inputName, symbol, nationCode);
+
+ private static LegacyOverseasActionDefinition[] CreateActions() =>
+ [
+ Candle("index-candle-5d", "해외지수 캔들 5일", LegacyOverseasActionSource.FixedIndex, 5, "8061"),
+ Candle("index-candle-20d", "해외지수 캔들 20일", LegacyOverseasActionSource.FixedIndex, 20, "8040"),
+ Candle("index-candle-60d", "해외지수 캔들 60일", LegacyOverseasActionSource.FixedIndex, 60, "8046"),
+ Candle("index-candle-120d", "해외지수 캔들 120일", LegacyOverseasActionSource.FixedIndex, 120, "8051"),
+ Candle("index-candle-240d", "해외지수 캔들 240일", LegacyOverseasActionSource.FixedIndex, 240, "8056"),
+ new(
+ "industry-current",
+ "미국 해외업종 현재가 1열판",
+ LegacyOverseasActionSource.Industry,
+ LegacyOverseasActionKind.Current,
+ "s5001",
+ "5001",
+ periodDays: null),
+ new(
+ "stock-current",
+ "해외종목 현재가 1열판",
+ LegacyOverseasActionSource.Stock,
+ LegacyOverseasActionKind.Current,
+ "s5001",
+ "5001",
+ periodDays: null),
+ Candle("stock-candle-5d", "해외종목 캔들 5일", LegacyOverseasActionSource.Stock, 5, "8061"),
+ Candle("stock-candle-20d", "해외종목 캔들 20일", LegacyOverseasActionSource.Stock, 20, "8040"),
+ Candle("stock-candle-60d", "해외종목 캔들 60일", LegacyOverseasActionSource.Stock, 60, "8046"),
+ Candle("stock-candle-120d", "해외종목 캔들 120일", LegacyOverseasActionSource.Stock, 120, "8051"),
+ Candle("stock-candle-240d", "해외종목 캔들 240일", LegacyOverseasActionSource.Stock, 240, "8056")
+ ];
+
+ private static LegacyOverseasActionDefinition Candle(
+ string actionId,
+ string label,
+ LegacyOverseasActionSource source,
+ int periodDays,
+ string sceneAlias) =>
+ new(
+ actionId,
+ label,
+ source,
+ LegacyOverseasActionKind.Candle,
+ "s8010",
+ sceneAlias,
+ periodDays);
+
+ private static void ValidateTargets()
+ {
+ if (RegisteredTargets.Count != FixedIndexTargetCount ||
+ RegisteredTargets.Select(target => target.TargetId)
+ .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
+ RegisteredTargets.Select(target => target.DisplayName)
+ .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
+ RegisteredTargets.Select(target => target.Symbol)
+ .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
+ RegisteredTargets.Any(target =>
+ !IsSafeId(target.TargetId) ||
+ !IsSafeCanonicalText(target.DisplayName, 200) ||
+ !string.Equals(target.DisplayName, target.InputName, StringComparison.Ordinal) ||
+ !IsSafeSymbol(target.Symbol) ||
+ target.NationCode.Length != 2 ||
+ target.NationCode.Any(character => character is < 'A' or > 'Z')))
+ {
+ throw new InvalidOperationException("The legacy overseas fixed-index catalog is invalid.");
+ }
+ }
+
+ private static void ValidateActions()
+ {
+ if (RegisteredActions.Count != ActionCount ||
+ RegisteredActions.Select(action => action.ActionId)
+ .Distinct(StringComparer.Ordinal).Count() != ActionCount ||
+ RegisteredActions.Any(action => !IsSafeId(action.ActionId)) ||
+ RegisteredActions.Any(action => action.PageSize != 0) ||
+ RegisteredActions.Any(action =>
+ action.Kind == LegacyOverseasActionKind.Candle != action.PeriodDays.HasValue) ||
+ RegisteredActions.Any(action =>
+ action.Source == LegacyOverseasActionSource.Industry &&
+ action.Kind != LegacyOverseasActionKind.Current) ||
+ RegisteredActions.Any(action =>
+ !LegacySceneCatalog.FindByCutAlias(action.SceneAlias).Any(scene =>
+ string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
+ (int)scene.PageSize == action.PageSize)))
+ {
+ throw new InvalidOperationException("The legacy overseas action catalog is invalid.");
+ }
+ }
+
+ private static bool IsSafeId(string value) =>
+ value.Length is > 0 and <= 64 &&
+ value.All(character => char.IsAsciiLetterOrDigit(character) || character == '-');
+
+ private static bool IsSafeCanonicalText(string value, int maximumLength) =>
+ value.Length is > 0 &&
+ value.Length <= maximumLength &&
+ string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
+ !value.Any(character => char.IsControl(character) || char.IsSurrogate(character));
+
+ private static bool IsSafeSymbol(string value) =>
+ IsSafeCanonicalText(value, 64) &&
+ value.All(character =>
+ char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
new file mode 100644
index 0000000..6d8a20f
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
@@ -0,0 +1,897 @@
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyOverseasSearchStatus
+{
+ Idle,
+ Loading,
+ Ready,
+ Error
+}
+
+public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20);
+
+public sealed class LegacyOverseasIndustrySearchRow
+{
+ internal LegacyOverseasIndustrySearchRow(
+ string selectionId,
+ OverseasIndustryIndexItem identity)
+ {
+ SelectionId = selectionId;
+ Identity = identity;
+ }
+
+ public string SelectionId { get; }
+
+ public string KoreanName => Identity.KoreanName;
+
+ public string InputName => Identity.InputName;
+
+ public string Symbol => Identity.Symbol;
+
+ public string NationCode => "US";
+
+ public string ForeignDomesticCode => "0";
+
+ internal OverseasIndustryIndexItem Identity { get; }
+}
+
+public sealed class LegacyOverseasStockSearchRow
+{
+ internal LegacyOverseasStockSearchRow(
+ string selectionId,
+ WorldStockSearchItem identity)
+ {
+ SelectionId = selectionId;
+ Identity = identity;
+ }
+
+ public string SelectionId { get; }
+
+ public string InputName => Identity.InputName;
+
+ public string Symbol => Identity.Symbol;
+
+ public string NationCode => Identity.NationCode;
+
+ public string ForeignDomesticCode => "1";
+
+ internal WorldStockSearchItem Identity { get; }
+}
+
+public sealed class LegacyOverseasIndustrySearchSnapshot
+{
+ internal LegacyOverseasIndustrySearchSnapshot(
+ LegacyOverseasSearchStatus status,
+ string query,
+ DateTimeOffset? retrievedAt,
+ IReadOnlyList results,
+ bool isTruncated,
+ LegacyOverseasIndustrySearchRow? selected,
+ string error)
+ {
+ Status = status;
+ Query = query;
+ RetrievedAt = retrievedAt;
+ Results = results;
+ IsTruncated = isTruncated;
+ Selected = selected;
+ Error = error;
+ }
+
+ public LegacyOverseasSearchStatus Status { get; }
+
+ public string Query { get; }
+
+ public DateTimeOffset? RetrievedAt { get; }
+
+ public IReadOnlyList Results { get; }
+
+ public int ResultCount => Results.Count;
+
+ public bool IsTruncated { get; }
+
+ public LegacyOverseasIndustrySearchRow? Selected { get; }
+
+ public string? SelectedId => Selected?.SelectionId;
+
+ public string Error { get; }
+}
+
+public sealed class LegacyOverseasStockSearchSnapshot
+{
+ internal LegacyOverseasStockSearchSnapshot(
+ LegacyOverseasSearchStatus status,
+ string query,
+ DateTimeOffset? retrievedAt,
+ IReadOnlyList results,
+ bool isTruncated,
+ LegacyOverseasStockSearchRow? selected,
+ string error)
+ {
+ Status = status;
+ Query = query;
+ RetrievedAt = retrievedAt;
+ Results = results;
+ IsTruncated = isTruncated;
+ Selected = selected;
+ Error = error;
+ }
+
+ public LegacyOverseasSearchStatus Status { get; }
+
+ public string Query { get; }
+
+ public DateTimeOffset? RetrievedAt { get; }
+
+ public IReadOnlyList Results { get; }
+
+ public int ResultCount => Results.Count;
+
+ public bool IsTruncated { get; }
+
+ public LegacyOverseasStockSearchRow? Selected { get; }
+
+ public string? SelectedId => Selected?.SelectionId;
+
+ public string Error { get; }
+}
+
+public sealed class LegacyOverseasWorkflowSnapshot
+{
+ internal LegacyOverseasWorkflowSnapshot(
+ long revision,
+ IReadOnlyList fixedIndexTargets,
+ LegacyOverseasFixedIndexTarget? selectedFixedIndex,
+ LegacyOverseasIndustrySearchSnapshot industry,
+ LegacyOverseasStockSearchSnapshot stock,
+ LegacyOverseasMovingAverageSelection movingAverages,
+ IReadOnlyList actions)
+ {
+ Revision = revision;
+ FixedIndexTargets = fixedIndexTargets;
+ SelectedFixedIndex = selectedFixedIndex;
+ Industry = industry;
+ Stock = stock;
+ MovingAverages = movingAverages;
+ Actions = actions;
+ }
+
+ public long Revision { get; }
+
+ public IReadOnlyList FixedIndexTargets { get; }
+
+ public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; }
+
+ public LegacyOverseasIndustrySearchSnapshot Industry { get; }
+
+ public LegacyOverseasStockSearchSnapshot Stock { get; }
+
+ public LegacyOverseasMovingAverageSelection MovingAverages { get; }
+
+ public IReadOnlyList Actions { get; }
+}
+
+public sealed record LegacyOverseasPlaylistSelection(
+ string GroupCode,
+ string Subject,
+ string GraphicType,
+ string Subtype,
+ string DataCode);
+
+public sealed record LegacyOverseasPlaylistDraft(
+ string ActionId,
+ string BuilderKey,
+ string SceneAlias,
+ IReadOnlyList Aliases,
+ int PageSize,
+ string InitialPageText,
+ string Title,
+ string Detail,
+ string Category,
+ string Market,
+ LegacyOverseasActionSource Source,
+ LegacyOverseasPlaylistSelection Selection,
+ LegacyOverseasMovingAverageSelection MovingAverages,
+ bool IsEnabled,
+ int FadeDuration);
+
+public sealed class LegacyOverseasWorkflowException : Exception
+{
+ public LegacyOverseasWorkflowException(string message)
+ : base(message)
+ {
+ }
+}
+
+///
+/// C# authority for the UC5 overseas selector. Search services return identities, while Web
+/// receives generation-bound selection ids and closed action ids only. A slower response can
+/// never replace the state established by a newer request of the same source.
+///
+public sealed class LegacyOverseasSelectionWorkflow
+{
+ public const int DefaultMaximumResults = 100;
+ public const int MaximumResults = 500;
+ public const int MaximumQueryLength = 64;
+
+ private const int DefaultFadeDuration = 6;
+ private const string InitialPageText = "1/1";
+ private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
+ private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
+
+ private static readonly IReadOnlyList NoIndustryResults =
+ Array.AsReadOnly(Array.Empty());
+
+ private static readonly IReadOnlyList NoStockResults =
+ Array.AsReadOnly(Array.Empty());
+
+ private readonly IOverseasIndustryIndexSearchService _industrySearchService;
+ private readonly IWorldStockSearchService _stockSearchService;
+ private readonly object _sync = new();
+
+ private LegacyOverseasFixedIndexTarget? _selectedFixedIndex;
+ private LegacyOverseasSearchStatus _industryStatus;
+ private string _industryQuery = string.Empty;
+ private DateTimeOffset? _industryRetrievedAt;
+ private IReadOnlyList _industryResults = NoIndustryResults;
+ private bool _industryIsTruncated;
+ private LegacyOverseasIndustrySearchRow? _selectedIndustry;
+ private string _industryError = string.Empty;
+ private long _industryGeneration;
+ private LegacyOverseasSearchStatus _stockStatus;
+ private string _stockQuery = string.Empty;
+ private DateTimeOffset? _stockRetrievedAt;
+ private IReadOnlyList _stockResults = NoStockResults;
+ private bool _stockIsTruncated;
+ private LegacyOverseasStockSearchRow? _selectedStock;
+ private string _stockError = string.Empty;
+ private long _stockGeneration;
+ private LegacyOverseasMovingAverageSelection _movingAverages;
+ private long _revision;
+
+ public LegacyOverseasSelectionWorkflow(
+ IOverseasIndustryIndexSearchService industrySearchService,
+ IWorldStockSearchService stockSearchService)
+ {
+ _industrySearchService = industrySearchService ??
+ throw new ArgumentNullException(nameof(industrySearchService));
+ _stockSearchService = stockSearchService ??
+ throw new ArgumentNullException(nameof(stockSearchService));
+ }
+
+ public LegacyOverseasWorkflowSnapshot Current
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return CreateSnapshot();
+ }
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
+ {
+ var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
+ lock (_sync)
+ {
+ if (!ReferenceEquals(_selectedFixedIndex, target))
+ {
+ _selectedFixedIndex = target;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public async Task SearchIndustriesAsync(
+ string query,
+ int maximumResults = DefaultMaximumResults,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedQuery = NormalizeQuery(query);
+ ValidateMaximumResults(maximumResults);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ long generation;
+ lock (_sync)
+ {
+ generation = checked(++_industryGeneration);
+ _industryStatus = LegacyOverseasSearchStatus.Loading;
+ _industryQuery = normalizedQuery;
+ _industryRetrievedAt = null;
+ _industryResults = NoIndustryResults;
+ _industryIsTruncated = false;
+ _selectedIndustry = null;
+ _industryError = string.Empty;
+ Touch();
+ }
+
+ try
+ {
+ var result = await _industrySearchService.SearchAsync(
+ normalizedQuery,
+ maximumResults,
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ lock (_sync)
+ {
+ if (generation != _industryGeneration)
+ {
+ return CreateSnapshot();
+ }
+ }
+
+ var validated = ValidateIndustryResult(
+ result,
+ normalizedQuery,
+ maximumResults,
+ generation);
+ lock (_sync)
+ {
+ if (generation != _industryGeneration)
+ {
+ return CreateSnapshot();
+ }
+
+ _industryStatus = LegacyOverseasSearchStatus.Ready;
+ _industryRetrievedAt = result.RetrievedAt;
+ _industryResults = validated;
+ _industryIsTruncated = result.IsTruncated;
+ _industryError = string.Empty;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ lock (_sync)
+ {
+ if (generation == _industryGeneration)
+ {
+ _industryStatus = LegacyOverseasSearchStatus.Idle;
+ _industryError = string.Empty;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ catch
+ {
+ lock (_sync)
+ {
+ if (generation != _industryGeneration)
+ {
+ return CreateSnapshot();
+ }
+
+ _industryStatus = LegacyOverseasSearchStatus.Error;
+ _industryRetrievedAt = null;
+ _industryResults = NoIndustryResults;
+ _industryIsTruncated = false;
+ _selectedIndustry = null;
+ _industryError = IndustryError;
+ Touch();
+ }
+
+ throw;
+ }
+ }
+
+ public async Task SearchStocksAsync(
+ string query,
+ int maximumResults = DefaultMaximumResults,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedQuery = NormalizeQuery(query);
+ ValidateMaximumResults(maximumResults);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ long generation;
+ lock (_sync)
+ {
+ generation = checked(++_stockGeneration);
+ _stockStatus = LegacyOverseasSearchStatus.Loading;
+ _stockQuery = normalizedQuery;
+ _stockRetrievedAt = null;
+ _stockResults = NoStockResults;
+ _stockIsTruncated = false;
+ _selectedStock = null;
+ _stockError = string.Empty;
+ Touch();
+ }
+
+ try
+ {
+ var result = await _stockSearchService.SearchAsync(
+ normalizedQuery,
+ maximumResults,
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ lock (_sync)
+ {
+ if (generation != _stockGeneration)
+ {
+ return CreateSnapshot();
+ }
+ }
+
+ var validated = ValidateStockResult(
+ result,
+ normalizedQuery,
+ maximumResults,
+ generation);
+ lock (_sync)
+ {
+ if (generation != _stockGeneration)
+ {
+ return CreateSnapshot();
+ }
+
+ _stockStatus = LegacyOverseasSearchStatus.Ready;
+ _stockRetrievedAt = result.RetrievedAt;
+ _stockResults = validated;
+ _stockIsTruncated = result.IsTruncated;
+ _stockError = string.Empty;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ lock (_sync)
+ {
+ if (generation == _stockGeneration)
+ {
+ _stockStatus = LegacyOverseasSearchStatus.Idle;
+ _stockError = string.Empty;
+ Touch();
+ }
+ }
+
+ throw;
+ }
+ catch
+ {
+ lock (_sync)
+ {
+ if (generation != _stockGeneration)
+ {
+ return CreateSnapshot();
+ }
+
+ _stockStatus = LegacyOverseasSearchStatus.Error;
+ _stockRetrievedAt = null;
+ _stockResults = NoStockResults;
+ _stockIsTruncated = false;
+ _selectedStock = null;
+ _stockError = StockError;
+ Touch();
+ }
+
+ throw;
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
+ lock (_sync)
+ {
+ var selected = _industryResults.FirstOrDefault(row =>
+ string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
+ ?? throw new KeyNotFoundException("The overseas-industry result is no longer current.");
+ if (!ReferenceEquals(_selectedIndustry, selected))
+ {
+ _selectedIndustry = selected;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
+ lock (_sync)
+ {
+ var selected = _stockResults.FirstOrDefault(row =>
+ string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
+ ?? throw new KeyNotFoundException("The overseas-stock result is no longer current.");
+ if (!ReferenceEquals(_selectedStock, selected))
+ {
+ _selectedStock = selected;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection()
+ {
+ lock (_sync)
+ {
+ if (_selectedFixedIndex is not null)
+ {
+ _selectedFixedIndex = null;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot ClearIndustrySelection()
+ {
+ lock (_sync)
+ {
+ if (_selectedIndustry is not null)
+ {
+ _selectedIndustry = null;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot ClearStockSelection()
+ {
+ lock (_sync)
+ {
+ if (_selectedStock is not null)
+ {
+ _selectedStock = null;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
+ {
+ var value = new LegacyOverseasMovingAverageSelection(ma5, ma20);
+ lock (_sync)
+ {
+ if (_movingAverages != value)
+ {
+ _movingAverages = value;
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+ }
+
+ ///
+ /// Builds all playlist and scene-selection fields from current native state. The caller can
+ /// select only a closed action id; it cannot supply aliases, data-code identities, paging,
+ /// moving-average text, or fade values.
+ ///
+ public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId)
+ {
+ ArgumentNullException.ThrowIfNull(actionId);
+ var action = LegacyOverseasActionCatalog.FindAction(actionId)
+ ?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
+
+ lock (_sync)
+ {
+ return action.Source switch
+ {
+ LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action),
+ LegacyOverseasActionSource.Industry => MaterializeIndustry(action),
+ LegacyOverseasActionSource.Stock => MaterializeStock(action),
+ _ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
+ };
+ }
+ }
+
+ private LegacyOverseasPlaylistDraft MaterializeFixedIndex(
+ LegacyOverseasActionDefinition action)
+ {
+ var target = _selectedFixedIndex ??
+ throw new LegacyOverseasWorkflowException("Select a fixed overseas index first.");
+ var movingAverages = MovingAveragesFor(action);
+ return CreateDraft(
+ action,
+ $"{target.DisplayName} 캔들 {action.PeriodDays}일",
+ "chart",
+ new LegacyOverseasPlaylistSelection(
+ "FOREIGN_INDEX",
+ target.InputName,
+ MovingAverageText(movingAverages),
+ "PRICE",
+ $"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"),
+ movingAverages);
+ }
+
+ private LegacyOverseasPlaylistDraft MaterializeIndustry(
+ LegacyOverseasActionDefinition action)
+ {
+ var industry = _selectedIndustry ??
+ throw new LegacyOverseasWorkflowException("Select a US overseas industry first.");
+ return CreateDraft(
+ action,
+ industry.KoreanName,
+ "plate",
+ new LegacyOverseasPlaylistSelection(
+ "FOREIGN_INDUSTRY",
+ industry.KoreanName,
+ "1열판기본",
+ "CURRENT",
+ $"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"),
+ default);
+ }
+
+ private LegacyOverseasPlaylistDraft MaterializeStock(
+ LegacyOverseasActionDefinition action)
+ {
+ var stock = _selectedStock ??
+ throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first.");
+ var movingAverages = MovingAveragesFor(action);
+ var current = action.Kind == LegacyOverseasActionKind.Current;
+ return CreateDraft(
+ action,
+ stock.InputName,
+ current ? "stock" : "chart",
+ new LegacyOverseasPlaylistSelection(
+ "FOREIGN_STOCK",
+ stock.InputName,
+ current ? "1열판기본" : MovingAverageText(movingAverages),
+ current ? "CURRENT" : "PRICE",
+ $"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"),
+ movingAverages);
+ }
+
+ private static LegacyOverseasPlaylistDraft CreateDraft(
+ LegacyOverseasActionDefinition action,
+ string title,
+ string category,
+ LegacyOverseasPlaylistSelection selection,
+ LegacyOverseasMovingAverageSelection movingAverages) =>
+ new(
+ action.ActionId,
+ action.BuilderKey,
+ action.SceneAlias,
+ action.Aliases,
+ action.PageSize,
+ InitialPageText,
+ title,
+ action.Label,
+ category,
+ "overseas",
+ action.Source,
+ selection,
+ movingAverages,
+ IsEnabled: true,
+ FadeDuration: DefaultFadeDuration);
+
+ private LegacyOverseasMovingAverageSelection MovingAveragesFor(
+ LegacyOverseasActionDefinition action) =>
+ action.SupportsMovingAverages ? _movingAverages : default;
+
+ private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) =>
+ selection switch
+ {
+ { Ma5: true, Ma20: true } => "MA5,MA20",
+ { Ma5: true } => "MA5",
+ { Ma20: true } => "MA20",
+ _ => string.Empty
+ };
+
+ private LegacyOverseasWorkflowSnapshot CreateSnapshot() =>
+ new(
+ _revision,
+ LegacyOverseasActionCatalog.FixedIndexTargets,
+ _selectedFixedIndex,
+ new LegacyOverseasIndustrySearchSnapshot(
+ _industryStatus,
+ _industryQuery,
+ _industryRetrievedAt,
+ _industryResults,
+ _industryIsTruncated,
+ _selectedIndustry,
+ _industryError),
+ new LegacyOverseasStockSearchSnapshot(
+ _stockStatus,
+ _stockQuery,
+ _stockRetrievedAt,
+ _stockResults,
+ _stockIsTruncated,
+ _selectedStock,
+ _stockError),
+ _movingAverages,
+ LegacyOverseasActionCatalog.Actions);
+
+ private static IReadOnlyList ValidateIndustryResult(
+ OverseasIndustryIndexSearchResult? result,
+ string expectedQuery,
+ int maximumResults,
+ long generation)
+ {
+ if (result is null || result.Items is null || result.RetrievedAt == default ||
+ !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
+ {
+ throw InvalidData("industry search envelope");
+ }
+
+ var items = result.Items.ToArray();
+ if (items.Length > maximumResults)
+ {
+ throw InvalidData("industry result bound");
+ }
+
+ var koreanNames = new HashSet(StringComparer.Ordinal);
+ var inputNames = new HashSet(StringComparer.Ordinal);
+ var symbols = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < items.Length; index++)
+ {
+ var item = items[index] ?? throw InvalidData("industry identity");
+ if (!IsSafeName(item.KoreanName) ||
+ !IsSafeName(item.InputName) ||
+ !IsSafeSymbol(item.Symbol) ||
+ !koreanNames.Add(item.KoreanName) ||
+ !inputNames.Add(item.InputName) ||
+ !symbols.Add(item.Symbol) ||
+ index > 0 && CompareIndustry(items[index - 1], item) > 0)
+ {
+ throw InvalidData("industry identity or ordering");
+ }
+ }
+
+ var rows = items.Select((item, index) =>
+ new LegacyOverseasIndustrySearchRow(
+ $"overseas-industry-{generation:D8}-{index + 1:D4}",
+ item));
+ return Array.AsReadOnly(rows.ToArray());
+ }
+
+ private static IReadOnlyList ValidateStockResult(
+ WorldStockSearchResult? result,
+ string expectedQuery,
+ int maximumResults,
+ long generation)
+ {
+ if (result is null || result.Items is null || result.RetrievedAt == default ||
+ !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
+ {
+ throw InvalidData("stock search envelope");
+ }
+
+ var items = result.Items.ToArray();
+ if (items.Length > maximumResults)
+ {
+ throw InvalidData("stock result bound");
+ }
+
+ var inputNames = new HashSet(StringComparer.Ordinal);
+ var identities = new HashSet();
+ for (var index = 0; index < items.Length; index++)
+ {
+ var item = items[index] ?? throw InvalidData("stock identity");
+ if (!IsSafeName(item.InputName) ||
+ !IsSafeSymbol(item.Symbol) ||
+ item.NationCode is not ("US" or "TW") ||
+ !inputNames.Add(item.InputName) ||
+ !identities.Add(item) ||
+ index > 0 && CompareStock(items[index - 1], item) > 0)
+ {
+ throw InvalidData("stock identity or ordering");
+ }
+ }
+
+ var rows = items.Select((item, index) =>
+ new LegacyOverseasStockSearchRow(
+ $"overseas-stock-{generation:D8}-{index + 1:D4}",
+ item));
+ return Array.AsReadOnly(rows.ToArray());
+ }
+
+ private static int CompareIndustry(
+ OverseasIndustryIndexItem left,
+ OverseasIndustryIndexItem right)
+ {
+ var comparison = CompareUpper(left.KoreanName, right.KoreanName);
+ if (comparison != 0)
+ {
+ return comparison;
+ }
+
+ comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal);
+ return comparison != 0
+ ? comparison
+ : string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
+ }
+
+ private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right)
+ {
+ var comparison = CompareUpper(left.InputName, right.InputName);
+ if (comparison != 0)
+ {
+ return comparison;
+ }
+
+ comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
+ return comparison != 0
+ ? comparison
+ : string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal);
+ }
+
+ private static int CompareUpper(string left, string right) =>
+ string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);
+
+ private static string NormalizeQuery(string query)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ if (!IsSafeText(query))
+ {
+ throw new ArgumentException("The overseas search query is invalid.", nameof(query));
+ }
+
+ var normalized = query.Trim();
+ if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
+ {
+ throw new ArgumentException(
+ $"The overseas search query must contain at most {MaximumQueryLength} safe characters.",
+ nameof(query));
+ }
+
+ return normalized;
+ }
+
+ private static void ValidateMaximumResults(int maximumResults)
+ {
+ if (maximumResults is < 1 or > MaximumResults)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(maximumResults),
+ $"Overseas results must be limited from 1 through {MaximumResults}.");
+ }
+ }
+
+ private static bool IsSafeName(string value) =>
+ value.Length is > 0 and <= 200 &&
+ string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
+ !value.Contains('|', StringComparison.Ordinal) &&
+ IsSafeText(value);
+
+ private static bool IsSafeSymbol(string value) =>
+ value.Length is > 0 and <= 64 &&
+ string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
+ IsSafeText(value) &&
+ value.All(character =>
+ char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
+
+ private static bool IsSafeText(string value)
+ {
+ foreach (var character in value)
+ {
+ var category = CharUnicodeInfo.GetUnicodeCategory(character);
+ if (char.IsControl(character) || char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ category is UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static LegacyOverseasWorkflowException InvalidData(string detail) =>
+ new($"The overseas selector returned an invalid {detail}.");
+
+ private void Touch() => _revision = checked(_revision + 1);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
index 91b3967..8734f8e 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
@@ -18,18 +18,24 @@ public sealed record LegacyOperatorPlaylistRow(
string PageText,
string StockCode,
string RawCutLabel,
- int FadeDuration)
+ int FadeDuration,
+ bool IsSelected = false,
+ string SceneAlias = "",
+ LegacySceneSelection? PlayoutSelection = null)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
- var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
- var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
+ var cutCode = SceneAlias.Length > 0
+ ? SceneAlias
+ : LegacyStockCutCatalog.ResolveCutAlias(
+ RawCutLabel,
+ StockName.Contains("(NXT)", StringComparison.Ordinal));
return new LegacyPlaylistEntry(
RowId,
cutCode,
IsEnabled,
FadeDuration,
- new LegacySceneSelection(
+ PlayoutSelection ?? new LegacySceneSelection(
MarketText,
StockName,
GraphicType,
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
new file mode 100644
index 0000000..7bd12ee
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
@@ -0,0 +1,822 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Text;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyThemeSort
+{
+ InputOrder,
+ GainRateDescending,
+ GainRateAscending
+}
+
+public enum LegacyThemeValueKind
+{
+ Current,
+ Expected
+}
+
+public sealed record LegacyThemeSortDefinition(
+ string Id,
+ string Label,
+ LegacyThemeSort Value);
+
+public sealed record LegacyThemeActionState(
+ string ActionId,
+ bool IsAvailable,
+ string? UnavailableReason);
+
+public sealed class LegacyThemeActionDefinition
+{
+ internal LegacyThemeActionDefinition(
+ string id,
+ string label,
+ string builderKey,
+ string code,
+ int pageSize,
+ LegacyThemeValueKind valueKind)
+ {
+ Id = id;
+ Label = label;
+ BuilderKey = builderKey;
+ Code = code;
+ Aliases = Array.AsReadOnly([code]);
+ PageSize = pageSize;
+ ValueKind = valueKind;
+ }
+
+ public string Id { get; }
+
+ public string Label { get; }
+
+ public string BuilderKey { get; }
+
+ public string Code { get; }
+
+ public IReadOnlyList Aliases { get; }
+
+ public int PageSize { get; }
+
+ public LegacyThemeValueKind ValueKind { get; }
+}
+
+public sealed class LegacyThemeSearchRow
+{
+ internal LegacyThemeSearchRow(
+ string resultId,
+ ThemeSelectionIdentity identity,
+ DataSourceKind source)
+ {
+ ResultId = resultId;
+ Identity = identity;
+ Market = identity.Market;
+ Session = identity.Session;
+ DisplayTitle = identity.Market == ThemeMarket.Nxt
+ ? $"{identity.ThemeTitle}(NXT)"
+ : identity.ThemeTitle;
+ Source = source;
+ }
+
+ public string ResultId { get; }
+
+ public ThemeMarket Market { get; }
+
+ public ThemeSession Session { get; }
+
+ public string DisplayTitle { get; }
+
+ public DataSourceKind Source { get; }
+
+ internal ThemeSelectionIdentity Identity { get; }
+}
+
+public sealed class LegacyThemePreviewRow
+{
+ internal LegacyThemePreviewRow(ThemeItemPreview item)
+ {
+ InputIndex = item.InputIndex;
+ ItemName = item.ItemName;
+ ItemCode = item.ItemCode;
+ }
+
+ public int InputIndex { get; }
+
+ public string ItemName { get; }
+
+ internal string ItemCode { get; }
+}
+
+public sealed record LegacySelectedThemeSummary(
+ ThemeMarket Market,
+ ThemeSession Session,
+ string DisplayTitle,
+ int PreviewItemCount,
+ bool PreviewIsTruncated);
+
+public sealed class LegacyThemeWorkflowSnapshot
+{
+ internal LegacyThemeWorkflowSnapshot(
+ object authority,
+ long revision,
+ string query,
+ ThemeSession nxtSession,
+ LegacyThemeSort sort,
+ IEnumerable searchResults,
+ bool searchIsTruncated,
+ string? selectedResultId,
+ ThemeSelectionIdentity? selectedIdentity,
+ IEnumerable previewItems,
+ bool previewIsTruncated)
+ {
+ Authority = authority;
+ Revision = revision;
+ Query = query;
+ NxtSession = nxtSession;
+ Sort = sort;
+ SearchResults = ReadOnly(searchResults);
+ SearchIsTruncated = searchIsTruncated;
+ SelectedResultId = selectedResultId;
+ SelectedIdentity = selectedIdentity;
+ PreviewItems = ReadOnly(previewItems);
+ PreviewIsTruncated = previewIsTruncated;
+ SelectedTheme = selectedIdentity is null
+ ? null
+ : new LegacySelectedThemeSummary(
+ selectedIdentity.Market,
+ selectedIdentity.Session,
+ selectedIdentity.Market == ThemeMarket.Nxt
+ ? $"{selectedIdentity.ThemeTitle}(NXT)"
+ : selectedIdentity.ThemeTitle,
+ PreviewItems.Count,
+ previewIsTruncated);
+ ActionStates = LegacyThemeWorkflow.CreateActionStates(
+ selectedIdentity,
+ PreviewItems.Count);
+ }
+
+ public long Revision { get; }
+
+ public string Query { get; }
+
+ public ThemeSession NxtSession { get; }
+
+ public LegacyThemeSort Sort { get; }
+
+ public string SortId => LegacyThemeWorkflow.SortId(Sort);
+
+ public IReadOnlyList SearchResults { get; }
+
+ public bool SearchIsTruncated { get; }
+
+ public string? SelectedResultId { get; }
+
+ public LegacySelectedThemeSummary? SelectedTheme { get; }
+
+ public IReadOnlyList PreviewItems { get; }
+
+ public bool PreviewIsTruncated { get; }
+
+ public IReadOnlyList ActionStates { get; }
+
+ internal object Authority { get; }
+
+ internal ThemeSelectionIdentity? SelectedIdentity { get; }
+
+ private static IReadOnlyList ReadOnly