송출 어댑터 상태를 확인하고 있습니다.
+
diff --git a/Web/playout-safety.js b/Web/playout-safety.js
index fd24b12..21541ff 100644
--- a/Web/playout-safety.js
+++ b/Web/playout-safety.js
@@ -32,6 +32,7 @@
if (!snapshot || snapshot.pending) return "busy";
if (snapshot.outcomeUnknown) return "outcome-unknown";
if (!snapshot.commandAvailable) return "unavailable";
+ if (snapshot.refreshFaulted && command !== "take-out") return "refresh-fault";
const onAir = snapshot.engineState === "PROGRAM" || Boolean(snapshot.onAirCode);
const prepared = snapshot.engineState === "PREPARED" || Boolean(snapshot.preparedCode);
@@ -48,10 +49,47 @@
payload.command === pending.command);
}
+ function canClearCommandError(error, sessionQuarantined) {
+ if (sessionQuarantined || !error) return !sessionQuarantined;
+ return error.outcomeUnknown !== true && error.code !== "WEB_TIMEOUT" &&
+ error.nonClearable !== true;
+ }
+
+ function playlistSnapshotLocked(snapshot) {
+ return Boolean(snapshot && (
+ snapshot.pending ||
+ snapshot.outcomeUnknown ||
+ snapshot.engineState === "PREPARED" ||
+ snapshot.engineState === "PROGRAM" ||
+ snapshot.preparedCode ||
+ snapshot.onAirCode));
+ }
+
+ function shouldClearRefreshError(error, refreshFaulted) {
+ return !refreshFaulted && error?.isRefreshFault === true;
+ }
+
+ function resolveStoredCatalogIndex(catalog, builderKey, cutCode) {
+ if (!Array.isArray(catalog)) return -1;
+ const exact = catalog.findIndex(item =>
+ item && item.reachable !== false && item.builderKey === builderKey);
+ if (exact >= 0) return exact;
+ const code = String(cutCode || "");
+ return catalog.findIndex(item => {
+ if (!item || item.reachable === false) return false;
+ const aliases = Array.isArray(item.aliases) ? item.aliases : [String(item.code || "")];
+ return aliases.includes(code);
+ });
+ }
+
return {
normalizeConnectionState,
responseTimeoutFromNative,
commandBlockReason,
- matchesPendingCommand
+ matchesPendingCommand,
+ canClearCommandError,
+ playlistSnapshotLocked,
+ shouldClearRefreshError,
+ resolveStoredCatalogIndex
};
});
diff --git a/Web/styles.css b/Web/styles.css
index 9f6aad5..416bcf5 100644
--- a/Web/styles.css
+++ b/Web/styles.css
@@ -239,6 +239,8 @@ td strong, td small { display: block; overflow: hidden; white-space: nowrap; tex
td strong { margin-bottom: 3px; color: #d7e2ee; font-size: 10px; }
td small { color: var(--muted-2); font-size: 8px; }
.row-type { display: inline-block; padding: 3px 5px; border-radius: 5px; background: var(--surface-3); color: #7f96b0; font: 8px Consolas, monospace; }
+.row-enabled { display: flex; align-items: center; gap: 3px; margin-top: 4px; color: var(--muted-2); font: 7px Consolas, monospace; }
+.row-enabled input { width: 11px; height: 11px; accent-color: var(--mint); }
input[type="checkbox"] { accent-color: var(--mint); }
.empty-state { position: absolute; inset: 36px 0 0; display: none; place-content: center; padding: 20px; color: var(--muted); text-align: center; }
.empty-state.show { display: grid; }
@@ -258,6 +260,9 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
+.playout-page-state { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin: 6px 13px 0; }
+.playout-page-state span { min-width: 0; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 5px; color: var(--muted-2); font: 7px Consolas, monospace; }
+.playout-page-state strong { display: block; margin-top: 2px; overflow: hidden; color: #a8bad0; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); }
.playout-error[hidden] { display: none; }
.playout-error strong, .playout-error span { display: block; }
@@ -266,6 +271,15 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; }
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
+.scene-data-preview { margin: -6px 13px 10px; overflow: hidden; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); }
+.scene-data-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); color: var(--muted-2); font: 7px Consolas, monospace; }
+.scene-data-preview-header strong { color: var(--mint); letter-spacing: .08em; }
+.scene-data-preview-body { max-height: 145px; overflow: auto; }
+.scene-data-empty { padding: 12px 8px; color: var(--muted-2); font-size: 8px; text-align: center; }
+.scene-data-row { display: grid; grid-template-columns: 72px minmax(110px, .8fr) minmax(160px, 1.4fr); gap: 7px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); align-items: center; }
+.scene-data-row span { color: #7290ad; font: 7px Consolas, monospace; text-transform: uppercase; }
+.scene-data-row strong { overflow: hidden; color: #a9bdd2; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
+.scene-data-row code { overflow: hidden; color: #d4e0ec; font: 8px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
.safe-area span, .safe-area strong, .safe-area small { display: block; }
.safe-area span { margin-bottom: 13px; color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .16em; }
@@ -274,6 +288,13 @@ input[type="checkbox"] { accent-color: var(--mint); }
.stage-corner { position: absolute; z-index: 2; width: 20px; height: 20px; border-color: var(--mint); border-style: solid; opacity: .7; }
.top-left { top: 10px; left: 10px; border-width: 1px 0 0 1px; }.top-right { top: 10px; right: 10px; border-width: 1px 1px 0 0; }.bottom-left { bottom: 10px; left: 10px; border-width: 0 0 1px 1px; }.bottom-right { right: 10px; bottom: 10px; border-width: 0 1px 1px 0; }
+.scene-selection-form { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)) auto; gap: 5px; margin: -6px 13px 10px; }
+.scene-selection-form label { min-width: 0; color: var(--muted-2); font: 7px Consolas, monospace; }
+.scene-selection-form input, .scene-selection-form select { width: 100%; min-width: 0; height: 27px; margin-top: 3px; padding: 0 7px; border: 1px solid var(--border-soft); border-radius: 5px; outline: 0; background: #08131f; color: #b8c8db; font-size: 8px; }
+.scene-selection-form input:focus, .scene-selection-form select:focus { border-color: rgba(86,168,255,.55); }
+.scene-selection-form button { align-self: end; height: 27px; padding: 0 9px; border: 1px solid rgba(86,168,255,.3); border-radius: 5px; background: rgba(86,168,255,.08); color: #8ec4ff; font-size: 8px; cursor: pointer; }
+.scene-selection-form button:disabled, .scene-selection-form input:disabled, .scene-selection-form select:disabled { cursor: not-allowed; opacity: .4; }
+
.playout-controls { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; padding: 0 13px 12px; }
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
@@ -283,6 +304,9 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }
+.add-cut:disabled { cursor: not-allowed; opacity: .28; }
+.live-data-selectable { cursor: pointer; }
+.live-data-selectable:hover td { background: rgba(86,168,255,.08); color: #d8e8fb; }
.event-log { display: flex; min-height: 0; flex: 1; flex-direction: column; margin: 0 13px; border: 1px solid var(--border-soft); border-radius: 8px; background: #08131f; overflow: hidden; }
.event-log-header { display: flex; align-items: center; justify-content: space-between; min-height: 33px; padding: 0 10px; border-bottom: 1px solid var(--border-soft); }
diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md
index a70f72b..6ac67a4 100644
--- a/docs/LEGACY_PLAYOUT_ANALYSIS.md
+++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md
@@ -1,57 +1,89 @@
# 원본 Tornado 송출 흐름 분석
-이 문서는 `MBN_STOCK_N`의 `MainForm`, `Scene` 35개 및 `PageN`/`Nxt_PageN`을 새 송출 어댑터와 대조한 기준선입니다. 원본 파일은 읽기만 했으며 새 저장소로 복사하지 않았습니다.
+이 문서는 `C:\Users\MD\source\repos\MBN_STOCK_N`의 `MainForm`, `Scene` 35개와 `PageN`/`Nxt_PageN`을 새 송출 runtime과 대조한 기준선이다. 원본은 읽기 전용으로만 조사했으며 소스, DB 비밀번호, 운영 설정과 실제 자산을 새 저장소로 복사하지 않았다.
## MainForm 호출 순서
-원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻습니다. 연결 성공 판정은 재연결 코드와 동일하게 반환값 `1`입니다.
+원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻는다. 원본의 `30001`은 당시 운영값일 뿐 새 Test endpoint의 기본값이 아니다. 새 회차에서는 Tornado2 `Tools > Option > Control > Network Server > TCP Port`의 실값을 사용하고, 반환값 `1`뿐 아니라 `OnHello`와 Network Monitoring `[R]`/`[S]`를 함께 확인해야 한다.
-여기의 `30001`은 원본 시스템의 당시 값일 뿐 새 Test endpoint의 기본값이나 검증값이 아닙니다. 새 설정은 격리 Test Tornado의 현재 `Tools > Option > Control > Network Server > TCP Port`를 직접 확인해 사용합니다. 반환값 `1`도 매뉴얼의 `OnHello` 또는 Network Monitoring `[R]`/`[S]` 확인을 대신하지 않습니다.
-
-원본 장면 PREPARE의 호출 순서는 다음과 같습니다.
+원본 PREPARE의 순서는 다음과 같다.
1. `LoadScene(Cuts\
.t2s, )`
-2. IN effect 플래그에 fade effect `7`과 `FadeInSec` 적용
+2. IN effect flag `1`에 fade effect `7`과 `FadeInSec` 적용
3. `BeginTransaction()`
-4. 장면별 데이터와 오브젝트 변경
+4. 장면별 object 값과 시각 속성 변경
5. `scene.QueryVariables()`
6. `EndTransaction()`
7. `player.Prepare(10, scene)`
-TAKE IN은 준비된 장면에 `Play(10)`을 호출하고 `m_TakeIn=true`로 전환합니다. TAKE OUT은 과거 `CutOut(10)` 대신 현재 운영 코드와 동일하게 `StopAll()`을 사용하고 on-air 상태를 해제합니다. NEXT는 `m_TakeIn`이 참일 때만 실행되므로 IDLE 또는 PREPARED 상태에서 바로 출력을 시작해서는 안 됩니다.
+새 `DynamicK3dSession`도 같은 순서를 사용한다. output channel이 명시된 경우에만 `EndTransactionOnChannel`을 사용하며 layout `10`은 `Prepare`/`Play`/`CutOut`에만 전달한다. transaction 중 실패하면 `RollbackTransaction`을 시도하고 원래 실패를 보존한다.
-새 `DynamicK3dSession`은 매뉴얼의 transaction 제한에 맞춰 오브젝트 변경을 `BeginTransaction`/`EndTransaction[OnChannel]` 안에서 끝낸 뒤 `QueryVariables`를 호출하고 `Prepare`합니다. `SetSceneEffectType`의 첫 인수도 layout이 아니라 IN effect 플래그 `1`로 전달하며, layout `10`은 `Prepare`/`Play`/`CutOut`에만 사용합니다. `TornadoPlayoutEngine`은 on-air 상태가 없으면 NEXT를 COM 호출 전에 거부합니다.
+공통 scene fade/background는 Web 입력이 아니라 로컬 trusted 설정이다. 원본 `ComboDi.SelectedIndex`에 맞춘 fade 기본값은 6이다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다.
-K3D의 Play/Stop 계열은 완료 이벤트가 별도인 비동기 명령입니다. 현재 callback handler를 아직 포팅하지 않았으므로 이전/on-air Scene을 명령 반환 직후 `Unload`하지 않고 연결 종료까지 보존합니다. 실제 운영의 장기 세션 정리는 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 기반으로 구현해야 합니다.
+## PREPARE, TAKE IN, NEXT, TAKE OUT 상태 전이
-## Scene 빌더의 범위
+- PREPARE는 선택 위치부터 다음 활성 row를 찾아 page 0의 실제 데이터를 조회하고 scene을 load/transaction/prepare한다. 성공한 전체 playlist와 선택 index는 immutable native snapshot으로 고정된다.
+- PREPARE/PROGRAM이 이미 활성인 상태에서 PREPARE를 다시 누르면 원본 toggle과 같이 `TakeOut(All)`/`StopAll`로 정리한다.
+- TAKE IN은 PREPARE 때의 DTO를 그대로 play하지 않는다. 원본이 `m_Super`를 초기화하고 `ONAirMode`를 다시 호출하는 것처럼 같은 frozen entry/page를 DB에서 새로 조회하고 `LoadScene` → transaction → `Prepare(10)`한 뒤 성공한 경우에만 `Play(10)`한다.
+- NEXT는 on-air 상태에서만 허용한다. 다음 page가 있으면 Page NEXT, 마지막 page면 다음 활성 playlist entry의 page 0으로 구분한다. 끝에서 wrap하지 않는다.
+- TAKE OUT은 현재 원본 운영 경로와 같이 `StopAll()`을 사용하고 on-air 상태를 해제한다.
-원본 `Scene` 폴더에는 35개 빌더가 있습니다. 단순 텍스트와 가시성 외에도 색상, 위치, 크기, crop key, path point, 그래프 데이터, 배경 texture/video 등 장면별 K3D 변형을 수행합니다. 이 로직은 데이터 조회와 WinForms 컨트롤에 강하게 결합되어 있어 단순한 Web 제목/설명 문자열로 대체할 수 없습니다.
+timeout, dispatch 뒤 cancellation 또는 결과가 불명확한 COM 실패는 `OutcomeUnknown` latch로 남긴다. 취소나 retry 가능한 실패로 낮추거나 같은 명령을 다시 보내지 않는다.
-현재 어댑터의 `PlayoutField`는 COM 경계를 검증하는 공통 `SetValue`/`SetVisible`만 표현합니다. Web bridge는 presentation용 `title`/`detail`을 장면 데이터인 것처럼 버리거나 추측하지 않고, PREPARE/NEXT에 검증된 scene code만 보냅니다. 따라서 승인된 `5001.t2s`/`5006.t2s` 연결 시험은 파일 load·prepare·play·stop 경로를 검증하지만 원본 시장 데이터가 채워진 방송 화면의 동등성을 증명하지 않습니다.
+## PageN, Page NEXT와 timer refresh
-장면 데이터를 포팅할 때는 scene code별 builder가 Core의 조회 결과를 명시적인 mutation DTO로 변환하고, 허용된 K3D 메서드만 어댑터가 실행하도록 확장해야 합니다. 오브젝트 이름이나 메서드를 Web 입력에서 임의로 전달하는 범용 reflection API는 만들지 않습니다.
+`PageN`과 `Nxt_PageN`은 조회 행 수를 5·6·12개 단위로 나눠 최대 20페이지의 `m_pcnt`를 계산한다. 새 구현의 대상은 `s5074`(5), `s5077`(6), `s5088`(12)이며 `pageCount = min(20, ceil(itemCount/pageSize))`를 사용한다.
-## PageN과 NEXT
+원본에는 같은 scene을 바꾸는 두 경로가 있다. 서로 혼동하면 안 된다.
-`PageN`과 `Nxt_PageN`은 조회 행 수를 5·6·12개 단위로 나눠 최대 20페이지의 `m_pcnt`를 계산합니다. `MainForm.Next_Scene`은 5단/6종목/12종목 장면에서 다음 플레이리스트 항목으로 즉시 이동하지 않고 다음 페이지 데이터를 같은 scene에 다시 채웁니다. 경로에 따라 새 scene을 load하거나 `GetPlayingScene(10)`을 얻어 transaction 후 다시 prepare/play합니다.
+1. Operator Page NEXT는 `btnNext_Click` → `Next_Scene(0)` 경로다. 다음 page의 fresh 데이터를 조회하고 새 scene을 `LoadScene` → transaction → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. playlist index는 유지하지만 `GetPlayingScene` in-place 갱신은 아니다.
+2. Timer refresh는 `timer1_Tick` → `Show_PlayList(idx: 1)` 경로다. current entry/page의 fresh DB DTO를 사용하고 K3D 호출은 `Play(10)` → `GetPlayingScene(10)` → transaction → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)` 순서로 현재 scene을 갱신한다. scene-level background와 transition effect는 다시 적용하지 않는다.
-현재 Web NEXT는 on-air 상태에서 다음 플레이리스트 cue를 prepare/play하는 어댑터 수준의 동작입니다. `m_pcnt`, 현재 페이지, 같은 scene의 in-place update 및 `GetPlayingScene` 기반 갱신은 아직 장면 builder 계층이 없으므로 구현 범위에 포함되지 않습니다. 운영 동등성 검증에서는 이 항목을 별도 완료 조건으로 추적해야 하며, 현재 Test 시퀀스 성공을 PageN 포팅 완료로 해석하지 않습니다.
+operator command를 시작할 때 timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤 해당 cut의 원본 `m_time`으로 첫 refresh를 예약하고, 첫 성공 이후에는 3초 간격으로 반복한다. Page NEXT 뒤에는 원본처럼 timer를 다시 시작하지 않는다. refresh 실패, timeout 또는 `OutcomeUnknown`이면 fault latch를 세우고 자동 반복을 중단하며 TAKE OUT 외 mutation 명령을 막는다.
-## 현재 Test 판정 범위
+마지막 부분 page는 남은 row만 채우고 나머지 object를 clear/hide한다. `s5088` NXT 비교와 조회 index는 모두 `i + pageIndex * 12`를 사용한다. page 경계값과 partial-page clearing은 자동 테스트로 검증했다.
-격리 Test에서 확인할 수 있는 범위는 다음과 같습니다.
+## 35개 Scene builder와 실제 데이터
-- x64 COM 활성화와 KTAP 연결/해제
-- 승인된 `.t2s`의 load와 scene alias
-- 오브젝트 transaction 종료 뒤 `QueryVariables`, layout 10 prepare
-- TAKE IN, 다음 cue의 NEXT, TAKE OUT `StopAll`
-- STA 직렬화, timeout, 프로세스 교체 및 오류 상태
+원본 `Scene` 폴더의 35개 builder는 모두 typed DTO와 COM-neutral mutation builder로 포팅했다. 값, visibility, face color, position, position key, scale, crop key, circle angle, path/path-shape, image/texture/video와 scene background를 개별 mutation으로 표현하고 `IPlayoutEngine` 뒤에서만 K3D 호출로 변환한다.
-다음 항목은 별도 장면 마이그레이션 작업이 필요합니다.
+- registry/catalog: 35개 builder 1:1
+- MainForm 도달 runtime: 34개 builder, active cut alias 45개
+- `s5032`/`s8018`: `5032`, `8018`, `8032` shared alias를 closed selection으로 분기
+- `s8086`: 원본 MainForm dispatch가 없어 active alias와 앱 runtime route 없이 diagnostic으로 유지
+- 실제 데이터 smoke: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일 통과
+- `s8086` diagnostic 조회 통과, 전체 Oracle/MariaDB query 55건 통과
-- 35개 scene builder의 데이터/시각 속성 동등성
-- `PageN`/`Nxt_PageN` 페이지 계산과 같은 scene 갱신
-- 배경 영상·texture 및 그래프/path mutation
-- 실제 시장 데이터와 원본 화면의 픽셀/내용 비교
-- `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 Scene unload
+builder별 object/mutation과 검증 상태는 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)에 있다. 실제 `.t2s`, DB 계정과 외부 CP949 파일은 Git에 넣지 않는다.
+
+## WebView 상태와 안전 경계
+
+Web catalog는 35개 builder와 도달 가능한 45개 alias를 제공하고 playlist row별 `enabled` flag를 보존한다. PREPARE가 성공하면 native snapshot을 freeze하며 pending command, `OutcomeUnknown`과 timeout quarantine 중에도 편집 잠금을 유지하므로 이후 Web 편집으로 NEXT 대상을 바꿀 수 없다.
+
+native status는 현재 entry, builder, page size/index/count, current row 수, last-page, next kind와 bounded typed preview를 authoritative 값으로 보낸다. preview는 object 값과 상태를 보여주되 asset path는 숨긴다. refresh active/next/last-success/fault도 표시한다. 성공한 TAKE OUT으로 native refresh state가 reset되면 전용 refresh error marker만 제거하고 다른 unknown/quarantine latch는 유지한다.
+
+Web 응답 제한 시간이 지나면 strict `ParseTimeoutQuarantine` 요청을 native에 보내고 MainWindow가 process-lifetime correlation latch를 먼저 세운 뒤 vendor session을 quarantine한다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure도 JavaScript 상관관계를 잃기 전에 같은 latch를 세우므로 WebView reload로 해제되지 않는다. pending request와 맞지 않는 늦은 응답은 state 전이 근거로 쓰지 않으며 같은 명령을 다시 보내지 않는다. `OutcomeUnknown`, native fault와 Web timeout은 UI를 닫는 것으로 해제되지 않는다.
+
+## Callback과 scene 수명
+
+vendor event handler의 `OnScenePlayed`, `OnCutOut`, `OnStopAll`은 managed callback queue로 연결돼 있다.
+
+- `OnScenePlayed` 성공 뒤에만 이전 retired scene을 unload/release한다.
+- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다.
+- `CutOut`/`StopAll` dispatch 전에 completion counter를 올리고 동기 호출 실패 시 원복한다. 성공 callback은 대응 counter를 하나 줄이고, stop/cut으로 중단된 Play는 별도 `OnScenePlayed`가 없을 수 있으므로 pending Play accounting을 취소한다.
+- pending lifecycle callback, queue overflow, callback failure 또는 connection generation 불일치가 있으면 Disconnect와 조기 unload를 하지 않고 session을 abandon/quarantine한다.
+- 이전 generation의 늦은 callback은 현재 scene state를 변경하지 않는다.
+
+따라서 장기 실행 시에도 callback으로 안전성이 확인된 retired scene만 unload된다.
+
+## 검증 판정 범위
+
+다음 자동·통합 검증은 완료됐다.
+
+- 35개 builder, loader, resolver, runtime coverage와 PageN 경계
+- 실제 Oracle/MariaDB 및 trusted CP949 source → DTO → mutation preflight
+- Debug/Release x64 Core, Playout, Infrastructure suite와 Web safety suite
+- Visual Studio 2026 Debug/Release x64 빌드
+- trusted Release x64 MSIX 생성, 설치와 package context 실행
+
+하지만 이번 마이그레이션 WebView workflow로 실제 Tornado2 PGM에 PREPARE/TAKE IN/Page NEXT/playlist NEXT/timer refresh/TAKE OUT을 보내고 Network Monitoring과 화면을 함께 확인하는 회차는 아직 승인되지 않았고 실행하지 않았다. 과거 고정 `5001 → 5006` runner 증거는 이 동등성 검증을 대신하지 않는다. 실제 운영 검증은 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)의 회차 승인과 반복 금지 절차를 따른다.
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index bab06af..83265fb 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -53,23 +53,21 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
- 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리
- 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시
- 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결
-- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인
+- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널, Test/Live 공통 폐쇄형 씬 allowlist, Live 이중 승인
- 완료: 현재 Tornado2 PGM 렌더 창에 대한 x64 K3D `KTAPConnect → Disconnect` 실제 왕복 및 Network Monitoring `[R] HELLO`/`[S] SUCCESS HELLO` 확인. 렌더 명령은 호출하지 않음
- 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단
-- 완료: 승인된 실제 PGM에서 고정 해시의 `5001 → 5006 → TAKE OUT` 호출. `Connect → Prepare/Play(5001) → Prepare/Play(5006) → StopAll → Disconnect` 전 단계 성공, 세 관찰 구간 `5051/5052/5093ms`, PGM의 5001·5006 화면과 최종 검은 화면 확인
-- 완료: 같은 회차 Network Monitoring의 `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE` 기록과 39개 연속 캡처를 manifest SHA-256으로 검증. 로컬 증거와 재현 절차는 [Tornado/K3D 운영 가이드](PLAYOUT.md)에 기록
-- 완료: 첫 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지
-- 목표 완료 판정: 최초 완료 조건의 격리 Test 인스턴스 검증은 이후 운영자의 현재 PGM 대상 회차별 명시 승인으로 대체되었습니다. 따라서 위 고정 테스트 컷 `5001`/`5006`의 실제 SDK 호출과 화면·Network Monitoring 증거를 이번 목표의 실제 호출 검증으로 인정합니다. 이 예외는 현재 PGM을 일반 Test 인스턴스로 분류하거나 앱의 Test/Live 안전 게이트를 완화하지 않습니다.
-- 배포 전 후속: 별도 승인된 Test 환경에서 MSIX WebView 컨텍스트의 실제 COM 활성화와 네 버튼 출력 관찰
-- 후속: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload
-- 후속: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 같은-scene 페이지 갱신 포팅
+- 과거 기본 경로 증거: 승인된 PGM 고정 runner에서 `5001 → 5006 → TAKE OUT`과 Network Monitoring `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE`, 39개 연속 캡처를 검증했다. 이는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh 또는 35개 builder 동등성 완료 증거가 아니다.
+- 완료: 첫 과거 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지
+- 현재 목표 미완료: 설치된 MSIX WebView workflow의 실제 Tornado2 `PREPARE → fresh TAKE IN → playlist/Page NEXT → timer refresh → TAKE OUT`, Network Monitoring과 PGM 동시 검증은 새 회차 승인 전이며 아직 실행하지 않았다.
+- 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting
+- 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증
### 화면 기능
- Oracle 플레이리스트 영구 저장/불러오기
- 10개 업무 탭의 실제 데이터 바인딩
- 테마, 전문가, VI, 비교, 수동 그래프 편집기
-- 35개 장면 빌더의 단계별 포팅
+- 35개 장면 빌더의 실제 Tornado2/PGM 화면 동등성 회차 검증
- 실제 Preview 이미지 및 씬/영상 자산 연결
## 의도적으로 제외한 항목
diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md
index 3401c5b..0fa94ae 100644
--- a/docs/PLAYOUT.md
+++ b/docs/PLAYOUT.md
@@ -93,8 +93,12 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
+| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 |
+| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web에서 변경할 수 없음 |
+| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 |
+| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
-| `testSceneAllowlist` | `Test`에서 허용한 테스트 scene name(code) 목록. 경로나 확장자는 넣지 않음 |
+| `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) |
| `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 |
| `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 |
| `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 |
@@ -112,6 +116,11 @@ MBN_STOCK_PLAYOUT_CLIENT_PORT
MBN_STOCK_PLAYOUT_SCENE_DIRECTORY
MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL
MBN_STOCK_PLAYOUT_LAYOUT_INDEX
+MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION
+MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND
+MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET
+MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT
+MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE
MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN
MBN_STOCK_PLAYOUT_QUEUE_CAPACITY
MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS
@@ -123,7 +132,7 @@ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
```
-`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
+`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
### KTAP 포트와 Network Monitoring 판정
@@ -131,7 +140,7 @@ K3DAsyncEngine 매뉴얼의 `KTAPConnect(bTCP, HostAddress, nHostPort, nClientPo
Tornado2의 `View > Network Monitoring Window`에서 `[R]`은 서버가 클라이언트 요청을 받은 기록, `[S]`는 서버가 응답을 보낸 기록이며 `TCPSession`은 TCP 세션 수입니다(매뉴얼 26~27쪽). 프로세스 감지, COM 등록 probe 또는 COM 객체 생성만으로는 이 기록이 생기지 않습니다. 기본 앱과 `--dry-run`, `--probe`, `--test-plan`은 KTAP를 호출하지 않으므로 빈 모니터가 정상입니다.
-상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. 현재 late-bound 어댑터는 282개 메서드 `IKAEventHandler` ABI를 안전하게 패키징하는 검증된 전략이 없어 `ktapHelloObserved`를 `null`로 보고합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다.
+상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. connect-only `--pgm-connect-diagnostic`은 렌더 API 표면을 제거한 별도 binding이라 callback을 관찰하지 않고 `ktapHelloObserved`를 `null`로 보고합니다. 일반 `IPlayoutEngine` 경로는 검증된 282-method `DynamicK3dEventHandler`로 `OnHello`와 lifecycle callback을 수신하지만, callback 미수신 상태를 성공으로 추정하지 않으며 Network Monitoring은 계속 사람이 대조합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다.
### PGM 네트워크 연결 전용 진단
@@ -206,7 +215,7 @@ powershell -NoProfile -ExecutionPolicy Bypass `
첫 회차는 `Connect: Success` 뒤 출력 전 `prepare-first: Rejected`로 중단되고 안전한 `Disconnect: Success`만 수행했습니다. 원인은 사전 검사에서 상대 cue를 절대 경로로 한 번 resolve한 뒤 그 이미 resolve된 cue를 `TornadoPlayoutEngine`에 전달해 엔진이 두 번째 resolve에서 거부한 것이었습니다. 수정 후 절대 경로는 승인 자산 검사와 파일 lease에만 사용하고, 엔진에는 고정 상대 cue인 `5001.t2s`와 `5006.t2s`를 전달합니다. 상대 cue와 검증용 절대 자산 경로가 분리되는 회귀 테스트도 추가했습니다. 이 회차는 `Play` 전에 결과가 명확히 거부되고 같은 대상의 Disconnect 성공까지 확인됐기 때문에 원인 수정 후 새 회차를 진행할 수 있었습니다. timeout, `OutcomeUnknown`, 대상 교체 또는 출력 결과가 불명확한 경우에는 자동·수동으로 반복하지 않고 quarantine 뒤 PGM 상태를 사람이 먼저 확인합니다.
-PGM 전용 시퀀스의 `outputChannel`은 의도적으로 비워 원본 `MainForm` 연결과 같은 `GetScenePlayer()`를 사용합니다. 검증되지 않은 임의 채널을 추정해 `GetScenePlayerOnChannel()`을 호출하지 않기 위함입니다. TAKE OUT은 원본의 현재 운영 경로와 같이 `TakeOut(All)`을 `StopAll()`로 매핑합니다. 과거 `CutOut(10)`보다 현재 재생기의 모든 레이어를 정리해 최종 PGM이 검은 화면으로 돌아오는 동작과 일치합니다. 이 검증은 두 승인 컷의 load/prepare/play/stop 경로를 증명하지만, 아직 포팅되지 않은 35개 scene builder와 `PageN` 데이터 표현의 동등성을 증명하지는 않습니다.
+PGM 전용 시퀀스의 `outputChannel`은 의도적으로 비워 원본 `MainForm` 연결과 같은 `GetScenePlayer()`를 사용합니다. 검증되지 않은 임의 채널을 추정해 `GetScenePlayerOnChannel()`을 호출하지 않기 위함입니다. TAKE OUT은 원본의 현재 운영 경로와 같이 `TakeOut(All)`을 `StopAll()`로 매핑합니다. 과거 `CutOut(10)`보다 현재 재생기의 모든 레이어를 정리해 최종 PGM이 검은 화면으로 돌아오는 동작과 일치합니다. 현재 35개 scene builder, 34개 도달 runtime, PageN과 실제 데이터 mutation은 포팅·자동/DB 검증을 마쳤지만, 이 과거 고정 runner는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh와 실제 화면 동등성을 검증한 회차가 아니므로 완료 증거로 사용하지 않습니다.
## 모드와 안전 게이트
@@ -313,7 +322,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
시퀀스는 `Connect → Prepare(5001) → TakeIn → 관찰 → Next(5006) → 관찰 → TakeOut(All) → 관찰 → Disconnect` 순서입니다. K3D의 Play/Stop 완료는 비동기 이벤트이므로 마지막 관찰 창이 끝나기 전에는 Disconnect하지 않습니다. 자동 재연결은 CLI가 강제로 비활성화합니다. 성공한 `TakeIn` 뒤 관찰 취소처럼 결과가 확정된 중단이면 `TakeOut(All)`을 한 번만 정리 단계로 요청한 뒤, 정리가 성공한 경우에만 `Disconnect`합니다. 이미 실행 결과가 불명확하거나 `TakeOut`이 어떤 비성공 결과라도 반환하면 출력이 남아 있을 수 있으므로 추가 출력 명령과 SDK `Disconnect`를 보내지 않습니다. 이때 `QuarantineAsync`가 같은 STA에서 제어 메서드 호출 없이 로컬 COM 참조만 해제한 다음 bounded 어댑터 폐기를 수행합니다. quarantine 자체를 완료하지 못하면 의도하지 않은 Disconnect보다 로컬 누수를 택해 일반 Dispose도 생략합니다. 어느 경우든 격리 모니터에서 최종 Test 출력 상태를 사람이 확인해야 합니다.
-현재 런타임은 vendor의 282-method event handler를 managed callback으로 아직 소비하지 않습니다. 따라서 `Play`, `CutOut`, `StopAll`의 COM 반환 직후 Scene을 `Unload`하지 않고 prepared/current/retired 참조를 연결 종료까지 보존합니다. 이는 두 장면만 쓰는 bounded 검증에서 조기 Unload로 화면 전환을 끊는 위험을 피하기 위한 동작입니다. 장시간 운영에서 Scene을 누적하지 않으려면 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 뒤에만 unload queue를 비우는 handler 포팅이 선행되어야 합니다.
+현재 런타임은 vendor의 282-method event handler에서 `OnScenePlayed`, `OnCutOut`, `OnStopAll`을 managed callback queue로 수신합니다. `OnScenePlayed` 성공 뒤에만 retired scene을 unload하고, pending Play callback이 있으면 TAKE OUT 이외의 mutation 명령을 fail-closed 차단합니다. `CutOut`/`StopAll` completion counter는 dispatch 직전에 증가하고 동기 실패 시 원복되며, 성공한 stop/cut callback은 중단된 pending Play accounting도 취소합니다. lifecycle callback이 pending이거나 실패·overflow가 발생하면 Disconnect나 조기 unload를 하지 않고 session을 abandon/quarantine하여 결과 불명확 상태를 보존합니다.
JSON 결과는 단계별 operation/result code와 `connectRequestIssued`, nullable `comActivationAttempted`, `lastKtapConnectState`, `ktapConnectAttempted`, nullable `ktapConnectAccepted`, nullable `ktapHelloObserved`, nullable `networkMonitoringRecordExpected`, `networkMonitoringCheckRequired`, nullable `networkMonitoringVerified`, `outputMayBeActive`, quarantine 시도·완료 여부를 제공합니다. `connectRequestIssued`는 엔진 API 요청일 뿐 KTAP 통신 증거가 아니며, `comActivationAttempted`도 COM 활성화 추정값일 뿐입니다. `lastKtapConnectState`는 `not-attempted`, `attempted`, `accepted-unconfirmed`, `failed` 중 하나입니다. `networkMonitoringRecordExpected`는 성공값을 받은 경우 `true`, dispatch가 없으면 `false`, local reflection/COM 실패 또는 timeout으로 서버 도달을 예측할 수 없으면 `null`입니다. `networkMonitoringCheckRequired`는 KTAP dispatch 경로에 들어간 모든 경우 `true`이며, `networkMonitoringVerified`는 앱이 Tornado2 UI를 판독하지 않으므로 항상 `null`입니다. 운영자가 직접 `[R]`/`[S]`를 확인해야 합니다. `outputMayBeActive: true`이면 자동 정리를 성공으로 확인하지 못했으므로 사람이 격리 출력을 확인해야 합니다. 특히 `Unavailable`, 취소, timeout은 연결 전 거부와 연결 도중 안전 게이트 변화가 같은 결과 code가 될 수 있으므로 추측하지 않습니다. 로컬 경로, 씬 code, PID, 창 제목, HRESULT 및 엔진 원문 오류는 출력하지 않습니다. `--test-plan`의 `runtimeProcessGateChecked: false`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다.
@@ -330,13 +339,15 @@ CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다
3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다.
4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다.
-WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 앱 재시작 전까지 유지됩니다. 브라우저 응답 제한 시간은 고정 15초가 아니라 검증된 native operation timeout에 5초 전달 여유를 더해 사용하고, native 명령이 끝나면 상관 응답을 놓친 경우에도 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다.
+2026-07-10 최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 송출 가능), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 이어 `5001 PREPARE → fresh TAKE IN → 최초 2초·이후 3초 timer refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고 Page NEXT 뒤 refresh가 정지했습니다. 5074는 5개 단위로 `1/20`부터 `20/20`까지 순서대로 진행되어 마지막에 `isLastPage=YES`, `END OF PLAYLIST`, NEXT 비활성이 되었으며, TAKE OUT 뒤 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다.
+
+WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. Web catalog는 35개 builder와 도달 가능한 45개 alias, row별 `enabled`를 보존하고 PREPARE 성공 뒤 playlist snapshot을 freeze합니다. pending command, `OutcomeUnknown`과 timeout quarantine 중에도 snapshot 편집 잠금을 유지합니다. native status의 current entry/builder/page size/current rows/last-page와 bounded preview가 authoritative 값이며 asset path는 preview에서 숨깁니다. refresh active/next/last-success/fault도 전달하고 refresh fault는 TAKE OUT 외 mutation 명령을 막습니다. 성공한 TAKE OUT 뒤에는 전용 refresh error marker만 reset하며 다른 unknown latch를 지우지 않습니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 유지됩니다. 브라우저 응답 제한 시간은 검증된 native operation timeout에 5초 전달 여유를 더해 사용하며, 상관 응답이 오지 않으면 strict `ParseTimeoutQuarantine` 요청을 native에 보냅니다. MainWindow는 await 전에 process-lifetime latch를 세우고 vendor session을 quarantine하므로 WebView reload로도 해제되지 않습니다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure로 JavaScript 상관관계가 사라지는 경우도 같은 native latch를 먼저 세웁니다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 native 명령이 끝나면 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다.
On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다.
-승인 컷의 load/play 경로와 원본 Scene/PageN 데이터 표현력은 서로 다른 검증 범위입니다. 현재 지원 범위와 아직 포팅되지 않은 복합 mutation·페이지 갱신은 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)을 기준으로 판단합니다.
+승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 남은 단계는 회차 승인을 받은 실제 Tornado2 Network Monitoring/PGM 검증입니다.
-일반 앱과 정규 Test 경로의 향후 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다.
+일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다.
패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다.
diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md
new file mode 100644
index 0000000..1766d62
--- /dev/null
+++ b/docs/PLAYOUT_OPERATIONS.md
@@ -0,0 +1,261 @@
+# MBN_STOCK_WEBVIEW 송출 운영·검증 절차
+
+이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다.
+
+## 현재 상태
+
+| 항목 | 상태 |
+|---|---|
+| 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 |
+| 자동 테스트 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과; 이후 테스트 추가 시 개수보다 실패 0건을 기준으로 재확인 |
+| Visual Studio 2026 | Debug/Release x64 빌드 성공 |
+| trusted Release x64 MSIX | 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 5001 timer refresh, 5074 playlist/Page NEXT와 1~20페이지 마지막 경계, TAKE OUT 정리를 확인 |
+| 실제 데이터 전체 scene smoke | 34개 도달 가능 builder 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과 |
+| `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight |
+| `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 |
+| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **미승인·미실행** |
+
+과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다. 이번 WebView runtime의 실제 Tornado2 Network Monitoring/PGM 회차는 여전히 승인되지 않았고 실행하지 않았다.
+
+## 절대 안전 규칙
+
+1. 앱의 기본값은 항상 `DryRun`으로 유지한다.
+2. `DryRun`, `--probe`, `--dry-run`, `--test-plan`은 `KTAPConnect`를 호출하지 않는다. 이때 Tornado2 Network Monitoring에 기록이 없는 것이 정상이다.
+3. 실제 출력은 승인된 테스트 scene, 지정된 PGM, 지정된 회차에서만 허용한다.
+4. Connect/PREPARE를 시작하기 전에 회차 범위 승인이 있어야 한다. **현재 PGM에 TAKE IN을 보내기 직전에는 같은 회차의 별도 명시적 승인을 다시 받아야 한다.**
+5. 승인에 없는 NEXT, Page NEXT, TAKE OUT 이외 명령 또는 다른 scene으로 범위를 넓히지 않는다.
+6. timeout, `OutcomeUnknown`, `WEB_TIMEOUT`, refresh fault, 응답/화면 불일치, 대상 프로세스 변경, callback 누락 상태에서는 같은 명령을 자동 또는 수동으로 반복하지 않는다.
+7. 실제 검증 회차에는 `reconnectEnabled=false`, `maximumReconnectAttempts=0`을 사용한다. 재접속은 새 회차와 새 승인으로만 수행한다.
+8. 안전 게이트, x64 vendor hash pin, scene allowlist, process/window/port ownership 검사를 완화하지 않는다.
+9. Web 입력으로 object 이름, 파일 경로, K3D method, 임의 SQL을 받지 않는다.
+10. 실제 on-air일 수 있는 scene을 수동으로 unload하거나 COM RCW를 강제 release하지 않는다.
+11. pending Play callback이 있으면 TAKE OUT 이외의 PREPARE/TAKE IN/NEXT/timer refresh를 실행하지 않는다. lifecycle callback이 남아 있으면 Disconnect하지 않는다.
+
+## Git 밖에서 준비할 항목
+
+다음 항목은 로컬 운영 설정 또는 승인된 외부 자산이다. Git, MSIX, 로그 첨부, 테스트 fixture에 복사하지 않는다.
+
+- 원본 test Cuts root: `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts`
+- 실제 `.t2s`, image, texture, video와 기타 scene 자산
+- `s5006`의 상대 자산 `Video\큐브배경.vrv`; 현재 위 Cuts root에는 없으므로 제공 전 실제 PREPARE 금지
+- `s5025` trusted CP949 수동 파일 디렉터리와 환경 변수 `MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY`
+- Oracle/MariaDB host, SID/service, database, user, password와 운영 query selector
+- K3D/Tornado vendor DLL, Interop, license와 설치 경로
+- `MBN_STOCK_K3D_NATIVE_SHA256`, `MBN_STOCK_K3D_INTEROP_SHA256` 승인 값과 승인 근거
+- MSIX 서명 인증서, 개인 키, 암호와 배포용 secrets
+- 실제 Tornado host/port, output channel, PGM 창 정보와 운영 설정
+- Network Monitoring/PGM screenshot·영상·manifest 등 실제 방송 증거
+
+Cuts root는 이번 작업에서 읽기 전용으로 취급한다. [`Test-LegacyCutCoverage.ps1`](../scripts/Test-LegacyCutCoverage.ps1)은 45개 active alias의 cut 존재 여부만 검사하며 자산을 복사하거나 수정하지 않는다.
+
+## 승인 게이트
+
+### Gate A: 회차 범위 승인
+
+Connect 또는 실제 PREPARE 전에 다음 내용을 운영 기록에 남긴다.
+
+```text
+[MBN_STOCK_WEBVIEW Tornado 검증 회차]
+회차 ID:
+예정 시각/최대 종료 시각:
+대상 Tornado2/PGM 식별값:
+승인된 test cut alias:
+승인된 selector와 시작 page:
+허용 동작과 횟수: CONNECT, PREPARE, TAKE IN, NEXT/Page NEXT, timer refresh 관찰, TAKE OUT, DISCONNECT
+Network Monitoring/PGM 관찰 담당자:
+비상 TAKE OUT 담당자:
+```
+
+대상, cut, selector, page, 횟수 중 하나라도 바뀌면 같은 회차 승인을 재사용하지 않는다.
+
+### Gate B: TAKE IN 직전 승인
+
+PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE IN 직전에 다음과 같이 명시적인 승인을 받아야 한다.
+
+```text
+회차 의 준비된 cut , page 을 현재 PGM에 TAKE IN 1회 실행하는 것을 승인한다.
+```
+
+이 문구가 없거나 회차 ID/cut/page가 다르면 TAKE IN을 실행하지 않는다. 이전 대화의 일반적 동의, 과거 회차 승인, DryRun 성공은 Gate B를 충족하지 않는다.
+
+## 실제 연결 전 offline preflight
+
+아래 단계는 Tornado에 명령을 보내지 않는다.
+
+1. 원본 기준선과 cut alias를 확인한다.
+
+ ```powershell
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacySceneBaseline.ps1
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 `
+ -CutRoot "C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts"
+ ```
+
+2. Debug/Release x64 테스트와 Web bridge 검사를 실행한다.
+
+ ```powershell
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Debug -p:Platform=x64
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj -c Release -p:Platform=x64
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Debug -p:Platform=x64
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj -c Release -p:Platform=x64
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Debug -p:Platform=x64
+ dotnet test .\tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj -c Release -p:Platform=x64
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-WebPlayout.ps1
+ ```
+
+3. 실제 DB smoke는 read-only query만 사용하는 별도 단계로 실행한다. DB 오류가 있으면 Tornado 단계로 넘어가지 않는다.
+
+ ```powershell
+ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release --no-restore
+ ```
+
+4. 설치된 trusted Release x64 MSIX가 package context에서 실행되는지 확인한다. `bin` 또는 loose EXE를 실제 검증에 사용하지 않는다.
+5. K3D Registry64, AMD64 PE, TypeLib/Interop metadata, 두 SHA-256 pin, license를 확인한다. DLL을 repo 또는 앱 폴더로 복사해 우회하지 않는다.
+6. 정확히 하나의 승인 대상 Tornado2 프로세스, PGM 창, Network Server TCP port와 LISTEN 소유권을 확인한다. 매뉴얼 예시 port를 추정해 사용하지 않는다.
+7. `s5025`를 선택했다면 trusted 외부 디렉터리와 파일 preflight가 성공해야 한다. `s5006`를 선택했다면 `Video\큐브배경.vrv`가 승인 asset root 안에 있어야 한다.
+8. 실제 COM을 사용하는 Test/Live playlist의 모든 cut이 로컬 폐쇄형 allowlist 안에 있고 selector가 closed enum/lookup 규칙을 통과하는지 DryRun에서 확인한다. allowlist가 비어 있으면 실제 모드 초기화 자체를 거부해야 한다.
+9. 로컬 playout 설정의 `legacySceneFadeDuration` 기본값 6과 `legacySceneBackgroundKind`를 확인한다. 공통 background를 쓸 때 `legacySceneBackgroundAssetPath`는 `sceneDirectory` 아래의 승인된 상대 경로여야 한다. `PlayoutSceneCompositionFactory`의 DryRun preflight가 파일 존재, 허용 확장자, root 탈출, 절대 경로와 reparse point를 모두 거부하는지 확인한다. Web에는 이 asset 경로를 보내지 않는다.
+10. Web DryRun에서 45개 active alias, row별 `enabled`, PREPARE 뒤 snapshot freeze, current entry/builder/page size/current rows/last-page/preview와 refresh 상태를 확인한다. pending command와 `OutcomeUnknown`/timeout quarantine에서도 playlist 편집이 잠겨야 한다. preview에 image/texture/video 경로가 나타나면 실제 회차를 중단한다.
+
+하나라도 실패하면 회차를 시작하지 않는다. 설정을 수정한 뒤 처음부터 새 preflight 결과를 만든다.
+
+## 정상 실행 순서와 관찰점
+
+### 1. Connect
+
+- Gate A와 모든 preflight를 다시 확인한다.
+- Connect는 한 번만 보낸다.
+- `OnHello`와 Network Monitoring의 request/response를 함께 확인한다.
+- Connect 결과가 accepted이지만 `OnHello` 또는 monitor 왕복이 확인되지 않으면 준비 완료로 간주하지 않는다.
+
+### 2. PREPARE
+
+PREPARE는 다음 native 순서를 수행해야 한다.
+
+1. 현재 활성 playlist 항목과 page 0을 native loader가 조회한다.
+2. scene load, trusted common background와 기본 fade 6 또는 승인된 로컬 fade/scene effect를 적용한다.
+3. `BeginTransaction` → allowlisted mutation → `QueryVariables` → `EndTransaction`을 실행한다.
+4. layout 10을 `Prepare`한다.
+
+관찰자는 Network Monitoring의 load/prepare 관련 왕복이 한 번씩인지, 앱이 `Prepared`와 정확한 cue/page를 표시하는지 확인한다. PGM에 의도하지 않은 on-air 변화가 있으면 즉시 회차를 중단하고 장애 절차를 따른다.
+
+### 3. TAKE IN
+
+- Gate B를 받은 뒤 한 번만 실행한다.
+- PREPARE 때의 DTO를 재사용하지 않고 frozen entry/page를 실제 DB에서 새로 조회하는지 확인한다.
+- fresh scene의 `LoadScene` → transaction → `Prepare(10)`이 명확히 성공한 뒤 `Play(10)`이 한 번 실행되는지와 `OnScenePlayed`를 확인한다.
+- PGM에서 원본과 비교할 object 이름별 값, 표시 상태, 색, 위치/크기, crop, path, image/texture/video, fade를 기록한다.
+- Network Monitoring의 두 번째 load/prepare와 PLAY request/response, PGM 화면 시각을 같은 증거 묶음에 보존한다.
+
+### 4. NEXT
+
+앱이 반환한 `nextKind`가 기준이다. Web에서 임의로 page/playlist 유형을 정하지 않는다.
+
+- `PageNext`: playlist index를 유지하고 원본 `Next_Scene(0)`처럼 다음 page의 fresh 데이터를 조회한 뒤 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로에서 `GetPlayingScene` in-place 갱신을 기대하지 않는다.
+- `PlaylistNext`: 현재 page가 마지막일 때 다음 활성 playlist 항목을 load/prepare/play한다. 비활성 항목은 앞으로 건너뛰고 끝에서 wrap하지 않는다.
+- 각 NEXT는 Gate A에 승인된 유형과 횟수 안에서만 한 번씩 실행한다.
+- `pageIndex`, `pageCount`, `itemCount`, 마지막 부분 page의 빈 object clear/hide와 PGM 화면을 함께 확인한다.
+- 모든 operator command는 timer를 먼저 멈춘다. TAKE IN/playlist NEXT 뒤에는 원본 `m_time`으로 첫 timer가 시작되지만 Page NEXT 성공 뒤에는 timer가 정지 상태로 남아야 한다.
+
+### 5. Timer refresh
+
+- 승인 범위에 포함된 경우에만 자동 refresh를 관찰한다. 첫 실행은 해당 cut의 원본 `m_time` 뒤, 이후 성공한 실행은 3초 간격이어야 한다.
+- 각 tick은 current entry/page의 fresh DB DTO를 사용하며 K3D 호출은 원본처럼 `Play(10)` → `GetPlayingScene(10)` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)` 순서다.
+- timer refresh는 scene-level background와 fade effect를 다시 적용하지 않는다.
+- 앱의 `refreshActive`, `refreshNextAt`, `refreshLastSuccessAt`, fault code/message와 PGM 데이터를 함께 기록한다.
+- refresh가 실패하거나 timeout/unknown이면 fault latch가 켜지고 반복이 즉시 끝나야 한다. TAKE OUT 이외의 명령을 시도하지 않는다.
+- 명확히 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 사라져야 한다. `OutcomeUnknown`, `WEB_TIMEOUT` 또는 native quarantine 표시는 함께 reset되면 안 된다.
+
+### 6. TAKE OUT과 Disconnect
+
+- 승인된 TAKE OUT 한 번으로 `TakeOut(All)`/`StopAll`을 실행한다.
+- `OnCutOut` 또는 `OnStopAll`, 앱 state clear와 PGM의 검은 화면 또는 승인된 종료 상태를 확인한다.
+- pending callback과 retired scene 정리가 끝난 뒤에만 Disconnect한다.
+- Network Monitoring에서 stop 계열 왕복과 BYE/disconnect를 확인한다.
+
+## Network Monitoring과 PGM 증거 체크리스트
+
+vendor monitor의 실제 명령 표기는 버전에 따라 다를 수 있으므로 추정 문자열로 성공을 만들지 않는다. raw 화면과 시각을 보존하고 다음 의미 단위로 대조한다.
+
+| 단계 | Network Monitoring | PGM/앱 |
+|---|---|---|
+| Connect | HELLO 또는 대응 connect request/response 한 쌍 | 앱 Connected, 대상 process generation 일치 |
+| PREPARE | scene load, mutation transaction, prepare의 중복 없는 왕복 | Prepared cue/page/row 수 일치, 의도하지 않은 on-air 없음 |
+| TAKE IN | fresh DB 결과의 두 번째 load/prepare 뒤 PLAY request/response | `OnScenePlayed`, 실제 object 값과 시각 상태 일치 |
+| Page NEXT | 같은 alias의 새 scene load/transaction/prepare/play 왕복 | playlist index 유지, page만 +1, 빈 slot clear/hide, refresh timer 정지 |
+| Playlist NEXT | 다음 cut load/prepare/play 왕복 | 다음 활성 항목과 page 0 표시 |
+| Timer refresh | PLAY, `GetPlayingScene` update transaction, prepare, PLAY의 중복 없는 왕복 | 첫 `m_time` 뒤 실행, 이후 3초, 같은 entry/page의 fresh 값, refresh 상태 정상 |
+| TAKE OUT | STOP/STOPALL 대응 왕복 | `OnCutOut`/`OnStopAll`, 승인된 종료 화면 |
+| Disconnect | BYE 또는 대응 disconnect 왕복 | callback pending 0, 연결 해제 |
+
+회차 증거에는 다음을 포함한다.
+
+- 회차 ID, package version, Git commit, 실행 시각과 timezone
+- cut alias, closed selector, page index/count, DB 조회 식별값; password와 connection string 제외
+- 각 앱 명령의 결과 code와 `OutcomeUnknown` 여부
+- 같은 시각의 Network Monitoring과 PGM 화면
+- PREPARE/TAKE IN/NEXT/timer refresh/TAKE OUT 전후 screenshot 또는 연속 capture
+- callback 순서, retired/unloaded scene 수, disconnect 결과
+- Web의 frozen entry/builder/page size/current rows/last-page/preview와 refresh status; asset path는 제외
+- 운영자 최종 판정과 불일치 목록
+
+증거는 Git 제외 로컬 경로 또는 승인된 증거 저장소에 보관한다. 캡처에 host, port, 계정, license 정보가 보이면 외부 전달 전에 별도 보안 절차로 처리하며 원본 증거를 repo에 넣지 않는다.
+
+## 장애 판정과 반복 금지
+
+| 상황 | 즉시 조치 | 재시도 조건 |
+|---|---|---|
+| DB/selector/asset preflight 실패, COM 호출 전 명시적 `Rejected` | 회차 중단, DryRun으로 복귀, 원인 기록 | offline 수정과 전체 preflight 후 새 회차 승인 |
+| Connect timeout 또는 `OnHello`/monitor 불일치 | 결과를 불명확으로 격리, Connect 반복 금지 | 운영자가 세션과 process/port 상태를 확인한 뒤 새 회차 승인 |
+| PREPARE/TAKE IN/NEXT/refresh/TAKE OUT timeout | `OutcomeUnknown`으로 취급, 같은 명령과 반대 명령 자동 실행 금지 | PGM/monitor/콜백을 사람이 확인하고 출력 안전을 복구한 뒤 새 회차 승인 |
+| `WEB_TIMEOUT` | strict timeout-quarantine message를 native에 전달. MainWindow가 process-lifetime latch를 먼저 세우고 vendor session을 quarantine; 늦은 응답, UI 재시도와 WebView reload로 해제 금지 | native status와 PGM을 사람이 대조하고 process를 새로 시작한 뒤 새 승인 |
+| refresh DB/scene/COM fault | refresh loop 중단, fault latch 유지, TAKE OUT 외 명령 금지 | 정상 TAKE OUT과 실제 화면 확인 후 offline 원인 수정, 새 회차 승인 |
+| 명령 성공 응답과 PGM 화면 불일치 | 앱 state를 신뢰하지 말고 회차 중단 | PGM 운영자가 실제 상태를 판정하고 새 회차 승인 |
+| callback 누락/지연 | scene을 unload하거나 disconnect 강제하지 않음 | callback 도착 또는 운영자의 수동 안전 판정 후 새 회차 |
+| Tornado process generation, 창, LISTEN 소유권 변경 | 모든 자동 동작 중지, 대상 격리 | 새 process를 처음부터 검증하고 새 승인 |
+| 연결 장애/Disconnect timeout | 자동 reconnect 금지, 연결·출력 상태를 불명확으로 기록 | 운영자가 Tornado 세션을 확인한 뒤 새 회차 |
+
+`OutcomeUnknown`은 단순 실패 code가 아니라 실제 출력 결과를 모른다는 latch다. Cancelled, Rejected, `WEB_TIMEOUT` 또는 UI 오류로 낮추지 않는다. timeout quarantine은 native process-lifetime latch이므로 WebView reload나 오류 창으로 해제되지 않는다. refresh fault marker는 성공한 TAKE OUT 뒤 native reset에만 맞춰 제거할 수 있지만 unknown/quarantine latch에는 영향을 주지 않는다. process를 재시작해 표시를 지우는 행위도 실제 출력 복구가 아니다.
+
+## Rollback과 unload
+
+### 명확히 출력 전 실패한 경우
+
+COM 명령 전 validation에서 명시적으로 거부됐고 Network Monitoring/PGM에 변화가 없음을 확인한 경우에만 offline 수정으로 돌아간다. 수정 후에는 기존 회차를 이어가지 않고 새 회차로 시작한다.
+
+### 출력이 명확히 active이고 엔진이 정상인 경우
+
+Gate A에 포함된 TAKE OUT을 한 번 실행한다. 성공 callback과 PGM 종료 화면을 확인한 뒤 Disconnect한다. 같은 TAKE OUT을 확인용으로 반복하지 않는다.
+
+### 결과가 불명확한 경우
+
+1. 앱에서 추가 PREPARE, TAKE IN, NEXT, timer refresh, TAKE OUT, Disconnect를 자동으로 보내지 않는다.
+2. 현재 앱 status, 마지막 명령, Network Monitoring, PGM을 캡처한다.
+3. 방송 운영자가 Tornado 본 프로그램/PGM에서 실제 출력과 세션을 판정한다.
+4. 필요한 수동 정리는 방송 운영 권한과 현장 절차로 수행한다. Codex나 앱이 임의 명령을 추정하지 않는다.
+5. 안전한 black/approved fallback과 세션 종료가 사람에게 확인된 뒤 앱을 종료한다.
+6. 장애 원인과 조치가 확정될 때까지 새 검증을 승인하지 않는다.
+
+### Scene 수명 규칙
+
+- `OnScenePlayed`가 새 scene의 재생을 확정하면 이전 scene만 retired queue로 이동한다.
+- `OnCutOut`은 해당 layout, `OnStopAll`은 전체 player의 on-air 참조를 정리할 근거다.
+- pending Play callback이 있으면 TAKE OUT 이외의 명령을 fail-closed 차단한다. pending `OnScenePlayed`/`OnCutOut`/`OnStopAll`이 있으면 Disconnect와 unload를 시도하지 않는다.
+- `CutOut`/`StopAll` pending counter는 SDK dispatch 직전에 증가하고 동기 호출 실패 시 감소한다. 성공 callback은 대응 counter를 하나 감소시키고 중단된 Play의 pending accounting을 취소한다. dispatch 뒤 cancellation/timeout이면 counter 상태를 성공으로 추정하지 않는다.
+- 이전 connection generation에서 늦게 온 callback은 현재 generation의 state나 scene을 정리하는 근거로 쓰지 않는다.
+- 장기 실행 중 retired scene은 callback으로 안전성이 확인된 뒤 STA queue 안에서 `Unload`와 release를 수행한다.
+- 강제 GC, 임의 RCW release, 현재 on-air scene unload를 rollback으로 사용하지 않는다.
+
+## 완료 판정
+
+실제 검증 회차는 다음을 모두 만족할 때만 성공이다.
+
+1. Gate A와 Gate B가 같은 회차에 기록돼 있다.
+2. 허용된 test cut과 selector만 사용했다.
+3. PREPARE → fresh DB TAKE IN → 승인된 Page NEXT/playlist NEXT → 원본 간격 timer refresh → TAKE OUT 순서가 중복 없이 완료됐다.
+4. 모든 결과가 명확한 성공이며 `OutcomeUnknown=false`다.
+5. Network Monitoring request/response와 PGM 화면이 같은 시각 증거로 남아 있다.
+6. 원본과 object 값, 표시, 색, 위치/크기, crop, path, asset, fade, page와 refresh 상태가 일치한다. Web preview에는 실제 asset path가 없다.
+7. callback과 unload 순서가 정상이고 Disconnect가 성공했다.
+8. DB password, vendor DLL/license, 인증서/private key, 실제 cut/asset, 운영 설정이 Git 변경에 포함되지 않았다.
+
+검증 후 결과를 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)의 해당 행에 기록한다. 일부 scene 성공이나 과거 runner 성공으로 나머지 행을 일괄 완료 처리하지 않는다.
diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md
new file mode 100644
index 0000000..b5516ba
--- /dev/null
+++ b/docs/SCENE_EQUIVALENCE.md
@@ -0,0 +1,168 @@
+# 35개 Scene 동등성 완료 매트릭스
+
+기준 시각은 2026-07-10이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다.
+
+이 문서에서 **구현 완료**는 DTO → mutation builder, 실제 데이터 loader, playlist selection resolver 또는 명시적 runtime route, 자동 테스트가 모두 존재한다는 뜻이다. **동등성 완료**는 여기에 실제 데이터와 승인된 Tornado2/PGM 검증까지 통과해야 한다. 따라서 현재 전체 목표는 아직 완료가 아니다.
+
+## 현재 판정
+
+| 항목 | 상태 | 근거와 제한 |
+|---|---|---|
+| 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 |
+| DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 |
+| loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 |
+| 자동 테스트 | 완료 | 최신 전체 재검증에서 Core Debug/Release x64 각각 779/779, Playout 각각 355/355, Infrastructure 각각 64/64, Web safety 11/11 통과. 테스트 추가에 따라 개수는 달라질 수 있으며 핵심 판정은 각 suite의 실패 0건이다. |
+| Visual Studio 2026 빌드 | 완료 | Debug/Release x64 빌드 성공 |
+| Release x64 MSIX | 완료 | trusted 개발 MSIX 생성·서명 검증·x64 설치·package context 실행 성공. 실제 DB DryRun에서 `5001 PREPARE → fresh TAKE IN → timer refresh → 5074 playlist NEXT → Page NEXT → TAKE OUT`과 5074의 `1/20`~`20/20`, 마지막 `END OF PLAYLIST`/NEXT 비활성, 편집 잠금 해제를 확인 |
+| 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55건이 모두 성공했다. |
+| 이번 마이그레이션의 실제 Tornado2/PGM | **미승인·미실행** | 현재 WebView → native workflow로 실제 PREPARE/fresh TAKE IN/Page NEXT/playlist NEXT/timer refresh/TAKE OUT, Network Monitoring, PGM 화면을 함께 검증하지 않음 |
+
+과거 고정 runner로 수행한 `5001 → 5006` PGM 왕복은 K3D 연결과 기본 load/play/stop 경로의 증거일 뿐이다. 35개 builder, 실제 DB mutation, Page NEXT와 현재 WebView workflow의 동등성 증거로 재사용하지 않는다.
+
+## 표기
+
+Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM-neutral 모델을 뜻한다.
+
+| 약어 | mutation |
+|---|---|
+| `V` | `PlayoutSetValue` |
+| `A` | `PlayoutSetAssetValue` |
+| `Vis` | `PlayoutSetVisible` |
+| `C` | `PlayoutSetFaceColor` |
+| `Pos` | `PlayoutSetPosition` |
+| `PosK` | `PlayoutSetPositionKey` |
+| `Scale` | `PlayoutSetScale` |
+| `Crop` | `PlayoutSetCropKey` |
+| `Angle` | `PlayoutSetCircleAngleKey` |
+| `Path` | `PlayoutSetPathPoints` |
+| `Shape` | `PlayoutSetPathShapePoints` |
+| `BgV` | `PlayoutSetBackgroundVideo` 및 background 사용 상태 |
+
+실제 데이터 상태는 다음과 같이 기록한다.
+
+- `DB-P`: 실제 Oracle/MariaDB 조회 → typed DTO → mutation preflight가 통과했다.
+- `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다.
+- `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다.
+- `TOR-W`: 이번 마이그레이션 runtime으로 해당 scene을 실제 Tornado2/PGM에서 검증하지 않았다. 회차 승인 전에는 실행하지 않는다.
+- `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다.
+
+## 자동 테스트 묶음
+
+아래 묶음은 최신 Core Debug/Release x64 전체 실행에 포함돼 실패 0건으로 통과했다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다.
+
+| 코드 | 테스트 파일 |
+|---|---|
+| `T-PARAM` | `ParameterizedMarketSceneBuildersTests`, `ParameterizedMarketSceneDataLoadersTests`, `LegacyParameterizedSceneRequestResolverTests` |
+| `T-PANEL` | `ReadOnlyPanelSceneBuildersTests`, `EquityPanelSceneDataLoadersTests`, `PanelSceneDataLoadersTests`, `MarketPanelSceneDataLoadersTests` |
+| `T-GRID` | `TabularMarketSceneBuildersTests`, `FoundationalSceneBuildersTests`, `GridMarketSceneDataLoadersTests`, `TraderQuoteSceneDataLoadersTests`, `LegacyGridMarketSceneRequestResolverTests` |
+| `T-MANUAL` | `TrustedManualSceneDataLoader5025Tests`, Infrastructure의 `S5025TrustedManualFileDataSourceTests` |
+| `T-COMP` | `ComparisonAndYieldSceneBuildersTests`, `ComparisonAndYieldSceneDataLoadersTests`, `ComparisonAndYieldLegacyRequestResolverTests` |
+| `T-CHART` | `ChartSceneBuilders5078To5084Tests`, `ChartSceneDataLoadersTests`, `ChartLegacySceneRequestResolverTests` |
+| `T-PAGED` | `PagedQuoteSceneBuildersTests`, `PagedQuoteSceneDataLoadersTests`, `ScenePagingTests`, `LegacyPlayoutWorkflowTests` |
+| `T-FOUND` | `FoundationalSceneBuildersTests`, `ManualSceneDataLoadersTests`, 해당 panel/grid loader 테스트 |
+| `T-5082` | `GridSceneBuilder5082Tests` |
+| `T-CANDLE` | `CandleSceneBuilderTests`, `CandleSceneDataLoaderTests` |
+
+## 35개 builder별 완료 판정
+
+| Builder / alias / page | 원본 기능 | 새 builder → loader → resolver/route | mutation | 자동 테스트 | 실제 데이터 | 실제 Tornado |
+|---|---|---|---|---|---|---|
+| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` |
+| `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-W` |
+| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` |
+| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` |
+| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` |
+| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-W` |
+| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` |
+| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-W` |
+| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
+| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
+| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` |
+| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-W` |
+| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` |
+| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-W` |
+| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` |
+| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` |
+| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` |
+| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` |
+| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-W` |
+| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-W` |
+| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` |
+| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` |
+| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` |
+| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
+| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
+| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
+| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` |
+| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` |
+| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` |
+| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-W` |
+| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` |
+| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-W` |
+| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` |
+| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-W` |
+| `s8086` / active alias 없음 / — | 유가·금 3열 원천. 원본 파일은 있으나 MainForm dispatch 없음 | `S8086SceneMutationBuilder` → `S8086DiagnosticSceneDataLoader`; 앱 runtime route 없음 | `V, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-DP` | `TOR-NA` |
+
+## 공통 호출 순서 동등성
+
+모든 runtime scene은 Web에서 object 이름이나 K3D 메서드를 받지 않는다. Web은 closed playlist selection만 보내고, loader가 조회 결과를 명시적 DTO로 만들며, registry의 typed builder가 allowlisted mutation을 생성한다.
+
+PREPARE의 공통 순서는 다음과 같다.
+
+1. 활성 playlist 항목과 cut alias를 결정하고 native loader로 page DTO를 조회한다.
+2. scene을 load하고 cue-level background와 fade/scene effect를 적용한다.
+3. `BeginTransaction`을 호출한다.
+4. builder가 생성한 mutation을 원본 순서대로 적용한다.
+5. `QueryVariables`를 호출한다.
+6. `EndTransaction`을 호출한다.
+7. layout 10을 `Prepare`한다.
+
+TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원본 `ONAirMode`와 같이 현재 playlist snapshot의 같은 entry/page를 실제 DB에서 다시 조회하고 새 scene을 `LoadScene` → transaction → `Prepare(10)`한 뒤, 그 prepare가 명확히 성공한 경우에만 `Play(10)`을 한 번 호출한다. PREPARE를 다시 누르는 원본 toggle은 `TakeOut(All)`/`StopAll`로 state를 정리한다. timeout 또는 `OutcomeUnknown`은 retry 가능한 실패로 낮추지 않는다.
+
+공통 fade와 배경은 Web 입력이 아니라 로컬 trusted playout 설정에서만 온다. `legacySceneFadeDuration`의 원본 기본값은 6이며, `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다.
+
+이 순서는 `RegisteredLegacySceneCueProviderTests`, `LegacyPlayoutWorkflowTests`, Playout의 `DynamicK3dSessionTests`, `TornadoPlayoutEngineTests`에서 검증한다. 실제 COM 형식과 vendor 구현은 계속 `IPlayoutEngine` 뒤에 있다.
+
+## PageN과 NEXT 동등성
+
+페이지 대상은 `s5074`(5), `s5077`(6), `s5088`(12)이다. 일반 시장과 NXT 시장이 같은 계산기를 사용하며 page index는 0부터 시작한다.
+
+- `pageCount = min(20, ceil(itemCount / pageSize))`다.
+- 경계값 `0`, `1`, `size-1`, `size`, `size+1`, `20*size`, `20*size+1`을 테스트한다.
+- 마지막 부분 페이지는 남은 행만 채우고 나머지 object를 clear/hide한다.
+- `s5088`의 NXT 인덱스는 비교와 조회 모두 `i + pageIndex * 12`를 사용한다.
+- 현재 항목에 다음 page가 있으면 operator Page NEXT는 playlist index를 유지하되 원본 `Next_Scene(0)`처럼 다음 page 데이터를 조회하고 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로는 `GetPlayingScene` in-place refresh가 아니다.
+- 마지막 page면 다음 활성 playlist 항목으로 이동한다. 비활성 항목은 원본처럼 앞으로 건너뛰며 끝에서 wrap하지 않는다.
+- 모든 operator command는 기존 refresh timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤에는 해당 cut의 원본 `m_time`으로 첫 timer를 시작하고, 첫 성공 뒤부터 3초 간격으로 갱신한다. Page NEXT 성공 뒤에는 timer를 다시 시작하지 않는다.
+- timer refresh는 current entry/page를 fresh DB DTO로 다시 만든 뒤, 원본 `timer1_Tick`의 K3D 호출 순서인 `Play(10)` → `GetPlayingScene(10)` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`으로 on-air scene을 갱신한다.
+
+## WebView 동등성 및 안전 상태
+
+- Web catalog는 35개 builder와 도달 가능한 45개 active cut alias를 모두 보존하며 `s8086`은 선택 불가 진단 항목으로 유지한다.
+- 각 playlist row는 원본 active flag에 대응하는 `enabled`를 제공한다. PREPARE 성공 시 전체 playlist와 선택 index를 immutable native snapshot으로 고정한다. pending command 또는 `OutcomeUnknown`/timeout quarantine 중에도 snapshot 편집을 잠그며, 그 상태가 해제될 때까지 Web 편집값으로 NEXT 대상을 바꾸지 않는다.
+- native 결과가 authoritative source이며 `currentEntryId`, `builderKey`, `pageSize`, `currentPageItemCount`, `isLastPage`, `nextKind`와 bounded mutation preview를 표시한다.
+- preview는 object별 typed 값/상태를 보여주되 image, texture, video와 공통 background의 실제 asset path는 항상 `configured` 같은 비밀 없는 상태로 치환한다.
+- refresh의 active/next/last-success/fault 상태를 표시한다. refresh DB·scene·COM 실패는 fault latch를 세워 TAKE OUT 외의 mutation 명령을 막고 자동 반복하지 않는다. 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 지우며 `OutcomeUnknown`이나 timeout quarantine은 함께 지우지 않는다.
+- native 응답 상관관계가 Web 제한 시간 안에 끝나지 않으면 `WEB_TIMEOUT`을 native에 보고한다. `ParseTimeoutQuarantine`이 검증한 요청은 MainWindow의 process-lifetime latch를 먼저 세운 뒤 vendor session을 quarantine하므로 WebView reload로 해제되지 않는다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 authoritative native/PGM 상태를 사람이 확인한다.
+
+## Callback과 scene 수명
+
+- `OnScenePlayed`가 성공한 뒤 이전 scene을 retired queue로 옮겨 안전하게 unload한다.
+- `OnCutOut`은 해당 layout의 on-air 참조를, `OnStopAll`은 player의 모든 on-air 참조를 정리할 근거다.
+- 이전 connection generation의 늦은 callback은 현재 state를 바꾸지 않는다.
+- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. 어떤 lifecycle callback이든 pending이면 Disconnect와 session recycle을 하지 않고 abandon/quarantine 및 `OutcomeUnknown`으로 보수적으로 전환한다.
+- `StopAll`/`CutOut`은 SDK dispatch 전에 각 pending completion counter를 올리고 동기 호출 실패 시 되돌린다. 성공 callback은 대응 counter를 정확히 하나 줄이며, 성공한 stop/cut은 중단된 Play가 뒤늦게 `OnScenePlayed`를 보내지 않을 수 있으므로 해당 pending Play accounting을 취소한다. dispatch 뒤 cancellation/timeout은 결과 불명확으로 취급한다.
+- 장기 실행 시 retired scene은 callback으로 안전성이 확인된 뒤 unload/release한다.
+
+운영·장애 복구·승인 절차는 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)를 따른다.
+
+## 남은 완료 조건
+
+다음 항목이 끝나기 전에는 이 문서의 전체 상태를 동등성 완료로 바꾸지 않는다.
+
+1. 승인된 외부 asset root에 `s5006`의 `Video\큐브배경.vrv`가 제공돼야 한다.
+2. 설치된 Release x64 MSIX의 WebView workflow로 승인된 테스트 scene에 대해 PREPARE → fresh TAKE IN → Page NEXT/playlist NEXT → timer refresh → TAKE OUT을 실행해야 한다.
+3. 같은 회차의 Tornado2 Network Monitoring 명령/응답과 PGM 데이터·페이지·종료 화면을 함께 보존하고 비교해야 한다.
+4. timeout, `OutcomeUnknown`, refresh fault, callback/연결 장애 복구 절차를 실제 검증 결과에 적용하고 운영자가 판정해야 한다.
+
+실제 DB/CP949 통합 검증과 자동 suite는 완료됐지만, 위 실제 Tornado2 검증은 여전히 회차 승인 전이며 실행하지 않았다.
diff --git a/docs/legacy-scene-source-hashes.json b/docs/legacy-scene-source-hashes.json
new file mode 100644
index 0000000..46751ba
--- /dev/null
+++ b/docs/legacy-scene-source-hashes.json
@@ -0,0 +1,42 @@
+{
+ "schemaVersion": 1,
+ "sourceRootHint": "MBN_STOCK_N/MBN_STOCK_N/Scene (external read-only source)",
+ "algorithm": "SHA-256",
+ "builders": [
+ { "builder": "s5001", "sourceFile": "Scene/s5001.cs", "sha256": "70A8DEBCF3E68E3EEDE93DF2B0D5F49F48800F01E75273D7B06BF9032B7136EC" },
+ { "builder": "s5006", "sourceFile": "Scene/s5006.cs", "sha256": "FCF9001E837B5D48C724867B8D3717E87E893732E3685CD82057E12C9B2E4E10" },
+ { "builder": "s5011", "sourceFile": "Scene/s5011.cs", "sha256": "A2DA771481016E6F009BE2E6B0C66A67EA836A5AEA343CD3046BC2E61D869AA6" },
+ { "builder": "s5016", "sourceFile": "Scene/s5016.cs", "sha256": "43E7113E495A379C2D1A0F0B2F07AA244104BA1286DE3FCCE18225D74934C7A5" },
+ { "builder": "s50160", "sourceFile": "Scene/s50160.cs", "sha256": "3A5F3591F3678AF2578224A9E01C233BE6A99185E3D96C1CEA1D716D06CEF42C" },
+ { "builder": "s5023", "sourceFile": "Scene/s5023.cs", "sha256": "7FF3855BB9B1D63314B28B3614CB887E82855D0EF65FB40DE07F8FDB2973F678" },
+ { "builder": "s5024", "sourceFile": "Scene/s5024.cs", "sha256": "306062555EAAC5AEC1EFAC4D6C75A930D207C5CF62652BB3E1531CFF60DD7A42" },
+ { "builder": "s5025", "sourceFile": "Scene/s5025.cs", "sha256": "123EEAAEB40EA3D43AB85F098BE26838D711927F3F4D50A353F9DB44E873D011" },
+ { "builder": "s5026", "sourceFile": "Scene/s5026.cs", "sha256": "F56550975963B0787D15901175747F4A5725C364E0AA83BA41D4C5588D8FE409" },
+ { "builder": "s5029", "sourceFile": "Scene/s5029.cs", "sha256": "84E566A2AA7D960CA5136710B8E50466C4A8757E7E63B494B4AA0771C6BE985E" },
+ { "builder": "s5032", "sourceFile": "Scene/s5032.cs", "sha256": "1199B47E833C9B2ECF70772D338DD22D82D25E4D2700C4B47B49FD78D7198576" },
+ { "builder": "s5037", "sourceFile": "Scene/s5037.cs", "sha256": "62E442802D4CE329782B2B3CA95890EECCB469A94FBF6E3085F2385837EAFBD5" },
+ { "builder": "s5074", "sourceFile": "Scene/s5074.cs", "sha256": "A9DF084C412F53DCB12AFF59B6D60B093EC793636D984F684C51EF383EAD7F5D" },
+ { "builder": "s5076", "sourceFile": "Scene/s5076.cs", "sha256": "4C1212F3062C8B02004882688BA07488E0A6C79467AC79D2341A869D47A7E8FC" },
+ { "builder": "s5077", "sourceFile": "Scene/s5077.cs", "sha256": "F806AF7A406E56C9523E236543D98B87148687D3BDB4F7914C1B303A54F3DDBC" },
+ { "builder": "s5078", "sourceFile": "Scene/s5078.cs", "sha256": "F233605E97C74CB502192EB540100E20F09E734EDC13A4579296705217B866C4" },
+ { "builder": "s5079", "sourceFile": "Scene/s5079.cs", "sha256": "848359AA045B2F98D02D36A425FFD3FEEA24E98151680654141F7FF831E81537" },
+ { "builder": "s5080", "sourceFile": "Scene/s5080.cs", "sha256": "5BEBC6C222A43D00BD14D02416407D934CBB7555E488ADCCC8CDD8C0DE88581F" },
+ { "builder": "s5081", "sourceFile": "Scene/s5081.cs", "sha256": "58413871174A117F619D0D4FF8040600FCF8F3C97349D0DBA4FFB3D7554A1090" },
+ { "builder": "s5082", "sourceFile": "Scene/s5082.cs", "sha256": "0A9D6D473053D1A737527432913A1D9153113E51A096410D9C964DF4DE4027D4" },
+ { "builder": "s5083", "sourceFile": "Scene/s5083.cs", "sha256": "7EACE01BA34CECC30E6B8F9895ED400A8E25376BC7616789A8130AD46AF2349E" },
+ { "builder": "s5084", "sourceFile": "Scene/s5084.cs", "sha256": "DE8AFF7CCA22400E6F9F4CECCBC136D48646AFEA7FD5DE7E5B8282B65B62947F" },
+ { "builder": "s5085", "sourceFile": "Scene/s5085.cs", "sha256": "45EB9637B4C5D7DF6C7EAA8F45C3D51E58968F50F75DDBCEC3015B09E6C5085C" },
+ { "builder": "s5086", "sourceFile": "Scene/s5086.cs", "sha256": "A6E390000D810E34E04267E309181C7B58F092EC682960DA8C4D7A7D9A0CEE91" },
+ { "builder": "s50860", "sourceFile": "Scene/s50860.cs", "sha256": "B9498FD3BCFCF3C7716CC9C070FF43D7DD22047DDAD2E02B04B6DFBDFAA5D91E" },
+ { "builder": "s5087", "sourceFile": "Scene/s5087.cs", "sha256": "75BEB7843D373A26355B848280DED477EBF79E2569A06623E8E4715351A8B18B" },
+ { "builder": "s5088", "sourceFile": "Scene/s5088.cs", "sha256": "8F8BF4204056D04D6ECA6681735B52A86C0B6EB14187E54F43685B13B57FA3D1" },
+ { "builder": "s6001", "sourceFile": "Scene/s6001.cs", "sha256": "1FEC555C61D78C5C0E605687CC30B81B0E46D9D45506F663A80D054E32CB9EDF" },
+ { "builder": "s6067", "sourceFile": "Scene/s6067.cs", "sha256": "D37CA7D6D719ABEEC52F8A38865CAD84913E04A5281D4AA852881EA0EED5E852" },
+ { "builder": "s8001", "sourceFile": "Scene/s8001.cs", "sha256": "EE70C6D8AF855948184E1291FAF03C7D8D084E2246B3FDC03D3029F406CE0C16" },
+ { "builder": "s8003", "sourceFile": "Scene/s8003.cs", "sha256": "03A01A164CB25F4B22630227B718EB7297C4224EDA0A1397CC99C79159A13A60" },
+ { "builder": "s8010", "sourceFile": "Scene/s8010.cs", "sha256": "CC28074583D84F630FBD7CBC6A3364EB66AB4E82B9F6F387A81E414EEB0AC448" },
+ { "builder": "s8018", "sourceFile": "Scene/s8018.cs", "sha256": "01B3EE85D728CCA922393FE80303475E959D578BEAED83ABA937BB20BC290170" },
+ { "builder": "s8067", "sourceFile": "Scene/s8067.cs", "sha256": "1E8A7C4367AA7E56FEE1DF0C13CACD28FC243E393850B865D6585FCFF570D465" },
+ { "builder": "s8086", "sourceFile": "Scene/s8086.cs", "sha256": "5373B8CBFF07163A2719317EBB69A57011A0006726C9899F9F63357C6AA52D3B" }
+ ]
+}
diff --git a/scripts/Test-LegacyCutCoverage.ps1 b/scripts/Test-LegacyCutCoverage.ps1
new file mode 100644
index 0000000..617cfc0
--- /dev/null
+++ b/scripts/Test-LegacyCutCoverage.ps1
@@ -0,0 +1,60 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $CutRoot
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not [IO.Path]::IsPathRooted($CutRoot)) {
+ throw 'CutRoot must be an absolute path.'
+}
+
+$root = [IO.Path]::GetFullPath($CutRoot)
+if (-not (Test-Path -LiteralPath $root -PathType Container)) {
+ throw 'CutRoot does not identify an existing directory.'
+}
+
+# Active MainForm aliases for the 34 reachable builders. s8086 intentionally has no alias.
+$aliases = @(
+ '5001', 'N5001', '5006', '5011', '5016', '50160',
+ '5023', '5024', '5025', '5026', '5029',
+ '8018', '8032', '5032', '5037',
+ '5074', '5076', '5077', '5078', '5079',
+ '5080', '5081', '5082', '5083', '5084', '5085',
+ '5086', '50860', '5087', '5088',
+ '6001', '6067', '8001', '8002', '8003',
+ '8035', '8061', '8040', '8046', '8051', '8056',
+ '8067', '5068', '5070', '5072'
+)
+
+$missing = [Collections.Generic.List[string]]::new()
+$unsafe = [Collections.Generic.List[string]]::new()
+foreach ($alias in $aliases) {
+ $path = Join-Path $root ($alias + '.t2s')
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ $missing.Add($alias)
+ continue
+ }
+
+ $item = Get-Item -LiteralPath $path -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0) {
+ $unsafe.Add($alias)
+ }
+}
+
+if ($missing.Count -ne 0 -or $unsafe.Count -ne 0) {
+ throw "Legacy cut coverage failed. Missing=$($missing.Count), Unsafe=$($unsafe.Count)."
+}
+
+[pscustomobject][ordered]@{
+ Status = 'Passed'
+ ReachableBuilders = 34
+ ActiveAliases = $aliases.Count
+ Missing = $missing.Count
+ Unsafe = $unsafe.Count
+}
diff --git a/scripts/Test-LegacySceneBaseline.ps1 b/scripts/Test-LegacySceneBaseline.ps1
new file mode 100644
index 0000000..38b9caa
--- /dev/null
+++ b/scripts/Test-LegacySceneBaseline.ps1
@@ -0,0 +1,52 @@
+[CmdletBinding()]
+param(
+ [string] $LegacyProjectRoot,
+ [string] $ManifestPath
+)
+
+$ErrorActionPreference = 'Stop'
+if ([string]::IsNullOrWhiteSpace($LegacyProjectRoot)) {
+ $LegacyProjectRoot = Join-Path $PSScriptRoot '..\..\MBN_STOCK_N\MBN_STOCK_N'
+}
+if ([string]::IsNullOrWhiteSpace($ManifestPath)) {
+ $ManifestPath = Join-Path $PSScriptRoot '..\docs\legacy-scene-source-hashes.json'
+}
+$legacyRoot = [IO.Path]::GetFullPath($LegacyProjectRoot)
+$manifestFile = [IO.Path]::GetFullPath($ManifestPath)
+
+if (-not (Test-Path -LiteralPath $legacyRoot -PathType Container)) {
+ throw 'The read-only legacy project root was not found.'
+}
+
+if (-not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) {
+ throw 'The legacy scene hash manifest was not found.'
+}
+
+$manifest = Get-Content -LiteralPath $manifestFile -Raw -Encoding UTF8 | ConvertFrom-Json
+if ($manifest.schemaVersion -ne 1 -or
+ $manifest.algorithm -ne 'SHA-256' -or
+ $manifest.builders.Count -ne 35) {
+ throw 'The legacy scene hash manifest is invalid.'
+}
+
+$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($entry in $manifest.builders) {
+ if (-not $seen.Add([string] $entry.builder)) {
+ throw 'The legacy scene hash manifest contains a duplicate builder.'
+ }
+
+ $relativePath = ([string] $entry.sourceFile).Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $sourceFile = [IO.Path]::GetFullPath((Join-Path $legacyRoot $relativePath))
+ $rootPrefix = $legacyRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
+ if (-not $sourceFile.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) -or
+ -not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
+ throw "Legacy source is missing for $($entry.builder)."
+ }
+
+ $actual = (Get-FileHash -LiteralPath $sourceFile -Algorithm SHA256).Hash
+ if (-not $actual.Equals([string] $entry.sha256, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Legacy source baseline changed for $($entry.builder)."
+ }
+}
+
+Write-Output "LEGACY_SCENE_BASELINE: PASS ($($seen.Count)/35)"
diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1
index 80e68c8..2564454 100644
--- a/scripts/Test-WebPlayout.ps1
+++ b/scripts/Test-WebPlayout.ps1
@@ -56,6 +56,60 @@ foreach ($script in $scripts) {
}
}
+$appScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\app.js') -Raw -Encoding UTF8
+$mainWindowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.xaml.cs') -Raw -Encoding UTF8
+$sceneCatalogMatches = [regex]::Matches(
+ $appScript,
+ 'builderKey:\s*"s[0-9]+"')
+if ($sceneCatalogMatches.Count -ne 35) {
+ throw "Web scene catalog must contain exactly 35 builder entries; found $($sceneCatalogMatches.Count)."
+}
+$selectionPresetMatches = [regex]::Matches(
+ $appScript,
+ '(?m)^\s+s[0-9]+:\s+\[')
+if ($selectionPresetMatches.Count -ne 34 -or
+ $appScript -notmatch 'Every reachable legacy builder must have a Web selection preset') {
+ throw "Web scene selection presets must cover exactly 34 reachable builders; found $($selectionPresetMatches.Count)."
+}
+if ($appScript -notmatch 'reachableAliases\.size !== 45' -or
+ $appScript -notmatch 'sceneCutAlias' -or
+ $appScript -notmatch 'sceneAliases\(definition\)\.includes\(storedCode\)' -or
+ $appScript -notmatch 'resolveStoredCatalogIndex') {
+ throw 'Web scene selection must expose and preserve all 45 active cut aliases.'
+}
+if ($appScript -match 'payload\.cue' -or
+ $appScript -notmatch 'payload\.playlist' -or
+ $appScript -notmatch 'payload\.selectedIndex') {
+ throw 'Web playout must send a PREPARE playlist snapshot and must not choose the NEXT cue.'
+}
+if ($appScript -notmatch 'const DEFAULT_FADE_DURATION = 6;' -or
+ $appScript -notmatch 'fadeDuration: DEFAULT_FADE_DURATION') {
+ throw 'Web playout default fade must match MainForm ComboDi.SelectedIndex 6.'
+}
+if ($appScript -notmatch 'normalizeScenePreviewFields' -or
+ $appScript -notmatch 'currentPageItemCount' -or
+ $appScript -notmatch 'renderSceneDataPreview') {
+ throw 'Web playout must render bounded authoritative scene data and complete page state.'
+}
+if ($appScript -notmatch 'row-enabled' -or
+ $appScript -notmatch 'item\.enabled = include\.checked' -or
+ $appScript -notmatch 'isPlaylistSnapshotLocked' -or
+ $appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or
+ $appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') {
+ throw 'Web playlist must expose the legacy active flag and freeze its native snapshot.'
+}
+if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or
+ $appScript -notmatch 'browserCorrelationQuarantined') {
+ throw 'Web response timeouts must latch the process-lifetime native quarantine.'
+}
+if ($mainWindowScript -notmatch 'OnNavigationStarting' -or
+ $mainWindowScript -notmatch 'OnProcessFailed' -or
+ $mainWindowScript -notmatch 'ReloadBrowserSafely' -or
+ $mainWindowScript -notmatch 'QuarantineIfBrowserCorrelationCanBeLost' -or
+ $mainWindowScript -notmatch 'Volatile\.Read\(ref _playoutCommandInFlight\)') {
+ throw 'Reload, navigation, and WebView process failure must quarantine in-flight playout correlation.'
+}
+
& $node --test $test
if ($LASTEXITCODE -ne 0) {
throw "Web playout tests failed with exit code $LASTEXITCODE."
@@ -64,5 +118,7 @@ if ($LASTEXITCODE -ne 0) {
[pscustomobject][ordered]@{
Status = 'Passed'
SyntaxFiles = $scripts.Count
+ SceneCatalogEntries = $sceneCatalogMatches.Count
+ ReachableSelectionPresets = $selectionPresetMatches.Count
TestFile = [IO.Path]::GetFileName($test)
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs
new file mode 100644
index 0000000..ecb6d26
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs
@@ -0,0 +1,418 @@
+#nullable enable
+
+using System.Collections.ObjectModel;
+using System.Data;
+
+namespace MMoneyCoderSharp.Data;
+
+///
+/// Describes one provider-neutral input parameter for a read query.
+/// Names are logical identifiers; the SQL supplies the provider marker
+/// (:name for Oracle or @name for MariaDB).
+///
+public sealed class DataQueryParameter
+{
+ private const int MaximumNameLength = 128;
+
+ public DataQueryParameter(string name, object? value, DbType? dbType = null)
+ {
+ if (!IsValidName(name))
+ {
+ throw new ArgumentException(
+ "A query parameter name is invalid.",
+ nameof(name));
+ }
+
+ if (dbType.HasValue && !Enum.IsDefined(dbType.Value))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(dbType),
+ "A query parameter database type is invalid.");
+ }
+
+ Name = name;
+ Value = value;
+ DbType = dbType;
+ }
+
+ public string Name { get; }
+
+ public object? Value { get; }
+
+ public DbType? DbType { get; }
+
+ public override string ToString() => "Data query parameter";
+
+ internal static bool IsValidName(string? name)
+ {
+ if (string.IsNullOrEmpty(name) || name.Length > MaximumNameLength)
+ {
+ return false;
+ }
+
+ if (!IsAsciiLetter(name[0]) && name[0] != '_')
+ {
+ return false;
+ }
+
+ for (var index = 1; index < name.Length; index++)
+ {
+ var character = name[index];
+ if (!IsAsciiLetter(character) && !char.IsAsciiDigit(character) && character != '_')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsAsciiLetter(char character) =>
+ character is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
+}
+
+///
+/// An immutable, validated SELECT/WITH query and its input parameters.
+/// The deliberately conservative validation fails closed for ambiguous SQL.
+/// Each logical parameter must occur exactly once; use distinct logical names
+/// when one value is intentionally bound at multiple SQL positions.
+///
+public sealed class DataQuerySpec
+{
+ private const int MaximumSqlLength = 65_536;
+
+ private static readonly HashSet StateChangingTokens = new(
+ [
+ "INSERT",
+ "UPDATE",
+ "DELETE",
+ "REPLACE",
+ "MERGE",
+ "CALL",
+ "EXEC",
+ "EXECUTE",
+ "CREATE",
+ "ALTER",
+ "DROP",
+ "TRUNCATE",
+ "GRANT",
+ "REVOKE",
+ "INTO",
+ "LOCK",
+ "FUNCTION",
+ "PROCEDURE",
+ "BEGIN",
+ "DECLARE",
+ "PRAGMA"
+ ],
+ StringComparer.OrdinalIgnoreCase);
+
+ private readonly ReadOnlyCollection _references;
+
+ public DataQuerySpec(string sql)
+ : this(sql, Array.Empty())
+ {
+ }
+
+ public DataQuerySpec(string sql, IEnumerable parameters)
+ {
+ if (string.IsNullOrWhiteSpace(sql) || sql.Length > MaximumSqlLength || sql.IndexOf('\0') >= 0)
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ ArgumentNullException.ThrowIfNull(parameters);
+
+ var suppliedParameters = parameters.ToArray();
+ if (suppliedParameters.Any(static parameter => parameter is null))
+ {
+ throw new ArgumentException(
+ "A query parameter collection is invalid.",
+ nameof(parameters));
+ }
+
+ var parametersByName = new Dictionary(
+ StringComparer.OrdinalIgnoreCase);
+ foreach (var parameter in suppliedParameters)
+ {
+ if (!parametersByName.TryAdd(parameter.Name, parameter))
+ {
+ throw new ArgumentException(
+ "A query parameter collection is invalid.",
+ nameof(parameters));
+ }
+ }
+
+ var parsed = Parse(sql);
+ if (parsed.Tokens.Count == 0 ||
+ (!string.Equals(parsed.Tokens[0], "SELECT", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(parsed.Tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)) ||
+ parsed.Tokens.Any(StateChangingTokens.Contains))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ var referencedNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var reference in parsed.References)
+ {
+ if (!referencedNames.Add(reference.Name) || !parametersByName.ContainsKey(reference.Name))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+ }
+
+ if (referencedNames.Count != parametersByName.Count)
+ {
+ throw new ArgumentException(
+ "A query parameter collection does not match the query.",
+ nameof(parameters));
+ }
+
+ // Preserve SQL placeholder order. This also keeps positional Oracle binding
+ // deterministic without constructing provider-specific parameter objects.
+ var orderedParameters = parsed.References
+ .Select(reference => parametersByName[reference.Name])
+ .ToArray();
+
+ Sql = sql;
+ Parameters = Array.AsReadOnly(orderedParameters);
+ _references = Array.AsReadOnly(parsed.References.ToArray());
+ }
+
+ public string Sql { get; }
+
+ public IReadOnlyList Parameters { get; }
+
+ ///
+ /// Verifies that all placeholders use the selected provider's marker.
+ ///
+ public void ValidateFor(DataSourceKind source)
+ {
+ var expectedMarker = source switch
+ {
+ DataSourceKind.Oracle => ':',
+ DataSourceKind.MariaDb => '@',
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(source),
+ "The query data source is invalid.")
+ };
+
+ if (_references.Any(reference => reference.Marker != expectedMarker))
+ {
+ throw InvalidSql(nameof(source));
+ }
+ }
+
+ public override string ToString() => "Parameterized read query";
+
+ private static ParsedSql Parse(string sql)
+ {
+ var tokens = new List();
+ var references = new List();
+
+ for (var index = 0; index < sql.Length;)
+ {
+ var current = sql[index];
+
+ if (IsPortableSqlWhitespace(current))
+ {
+ index++;
+ continue;
+ }
+
+ if (current > 0x7f)
+ {
+ // Unquoted identifiers and whitespace differ between providers.
+ // Unicode remains supported inside quoted text and comments only.
+ throw InvalidSql(nameof(sql));
+ }
+
+ if (current == '-' && index + 1 < sql.Length && sql[index + 1] == '-')
+ {
+ if (index + 2 < sql.Length && !IsPortableLineCommentWhitespace(sql[index + 2]))
+ {
+ // MariaDB only recognizes -- as a comment when whitespace follows.
+ // Reject the cross-provider ambiguity instead of skipping SQL text.
+ throw InvalidSql(nameof(sql));
+ }
+
+ index += 2;
+ while (index < sql.Length && sql[index] is not '\r' and not '\n')
+ {
+ index++;
+ }
+
+ continue;
+ }
+
+ if (current == '/' && index + 1 < sql.Length && sql[index + 1] == '*')
+ {
+ if ((index + 2 < sql.Length && sql[index + 2] == '!') ||
+ (index + 3 < sql.Length &&
+ (sql[index + 2] is 'M' or 'm') &&
+ sql[index + 3] == '!'))
+ {
+ // MySQL/MariaDB execute version and /*M! comments as SQL.
+ throw InvalidSql(nameof(sql));
+ }
+
+ var commentEnd = sql.IndexOf("*/", index + 2, StringComparison.Ordinal);
+ if (commentEnd < 0)
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ index = commentEnd + 2;
+ continue;
+ }
+
+ if (current is '\'' or '"' or '`')
+ {
+ if (current == '\'' &&
+ index > 0 &&
+ sql[index - 1] is 'Q' or 'q')
+ {
+ // Oracle alternative quoting has delimiter-dependent rules.
+ // Reject it instead of interpreting its body with standard quoting.
+ throw InvalidSql(nameof(sql));
+ }
+
+ index = SkipQuotedText(sql, index, current);
+ continue;
+ }
+
+ if (current == ';')
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ if (current is ':' or '@')
+ {
+ if (index > 0 && IsAmbiguousPlaceholderBoundary(sql[index - 1]))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ var nameStart = index + 1;
+ if (nameStart >= sql.Length ||
+ (!IsAsciiLetter(sql[nameStart]) && sql[nameStart] != '_'))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ var nameEnd = nameStart + 1;
+ while (nameEnd < sql.Length && IsParameterNameCharacter(sql[nameEnd]))
+ {
+ nameEnd++;
+ }
+
+ if (nameEnd < sql.Length && !IsValidPlaceholderTerminator(sql[nameEnd]))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ var name = sql[nameStart..nameEnd];
+ if (!DataQueryParameter.IsValidName(name))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ references.Add(new ParameterReference(current, name));
+ index = nameEnd;
+ continue;
+ }
+
+ if (IsAsciiLetter(current) || current == '_')
+ {
+ var tokenEnd = index + 1;
+ while (tokenEnd < sql.Length && IsSqlTokenCharacter(sql[tokenEnd]))
+ {
+ tokenEnd++;
+ }
+
+ tokens.Add(sql[index..tokenEnd]);
+ index = tokenEnd;
+ continue;
+ }
+
+ if (!char.IsAsciiDigit(current) && !IsAllowedSqlPunctuation(current))
+ {
+ throw InvalidSql(nameof(sql));
+ }
+
+ index++;
+ }
+
+ return new ParsedSql(tokens, references);
+ }
+
+ private static int SkipQuotedText(string sql, int quoteStart, char quote)
+ {
+ for (var index = quoteStart + 1; index < sql.Length; index++)
+ {
+ if (sql[index] == '\\')
+ {
+ // MariaDB backslash escaping depends on SQL_MODE. Reject the
+ // ambiguity so executable text can never be mistaken for a literal.
+ throw InvalidSql(nameof(sql));
+ }
+
+ if (sql[index] != quote)
+ {
+ continue;
+ }
+
+ if (index + 1 < sql.Length && sql[index + 1] == quote)
+ {
+ index++;
+ continue;
+ }
+
+ return index + 1;
+ }
+
+ throw InvalidSql(nameof(sql));
+ }
+
+ private static bool IsParameterNameCharacter(char character) =>
+ IsAsciiLetter(character) || char.IsAsciiDigit(character) || character == '_';
+
+ private static bool IsSqlTokenCharacter(char character) =>
+ IsParameterNameCharacter(character) || character is '$' or '#';
+
+ private static bool IsPortableLineCommentWhitespace(char character) =>
+ IsPortableSqlWhitespace(character);
+
+ private static bool IsPortableSqlWhitespace(char character) =>
+ character is ' ' or '\t' or '\r' or '\n' or '\f';
+
+ private static bool IsAmbiguousPlaceholderBoundary(char character) =>
+ char.IsLetterOrDigit(character) ||
+ char.GetUnicodeCategory(character) is
+ System.Globalization.UnicodeCategory.ConnectorPunctuation or
+ System.Globalization.UnicodeCategory.NonSpacingMark or
+ System.Globalization.UnicodeCategory.SpacingCombiningMark or
+ System.Globalization.UnicodeCategory.Format ||
+ character is '$' or '#' or ':' or '@';
+
+ private static bool IsValidPlaceholderTerminator(char character) =>
+ IsPortableSqlWhitespace(character) ||
+ character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or
+ '=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or ';';
+
+ private static bool IsAllowedSqlPunctuation(char character) =>
+ character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or
+ '=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or '~';
+
+ private static bool IsAsciiLetter(char character) =>
+ character is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
+
+ private static ArgumentException InvalidSql(string parameterName) =>
+ new("The read query is invalid or ambiguous.", parameterName);
+
+ private sealed record ParameterReference(char Marker, string Name);
+
+ private sealed record ParsedSql(
+ IReadOnlyList Tokens,
+ IReadOnlyList References);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs
index 04074ab..4956794 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/IDataQueryExecutor.cs
@@ -25,6 +25,29 @@ public interface IDataQueryExecutor
string tableName,
string query,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes an immutable parameterized read query asynchronously.
+ /// Existing adapters remain source-compatible, but must opt in before
+ /// accepting a spec that contains parameters.
+ ///
+ Task ExecuteAsync(
+ DataSourceKind source,
+ string tableName,
+ DataQuerySpec query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ query.ValidateFor(source);
+
+ if (query.Parameters.Count > 0)
+ {
+ throw new NotSupportedException(
+ "This query executor does not support parameterized queries.");
+ }
+
+ return ExecuteAsync(source, tableName, query.Sql, cancellationToken);
+ }
}
///
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
index 9de150a..fc8bad3 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
@@ -31,7 +31,8 @@ public enum PlayoutOperation
Prepare,
TakeIn,
Next,
- TakeOut
+ TakeOut,
+ UpdateOnAir
}
public enum PlayoutResultCode
@@ -67,6 +68,125 @@ public sealed record PlayoutField(
string? Value = null,
bool? IsVisible = null);
+[Flags]
+public enum PlayoutVectorComponents
+{
+ X = 1,
+ Y = 2,
+ XY = X | Y,
+ Z = 4,
+ ZX = Z | X,
+ YZ = Y | Z,
+ XYZ = X | Y | Z
+}
+
+[Flags]
+public enum PlayoutCropEdges
+{
+ Left = 1,
+ Top = 2,
+ Right = 4,
+ Horizontal = Left | Right,
+ Bottom = 8,
+ Vertical = Top | Bottom,
+ All = Left | Top | Right | Bottom
+}
+
+[Flags]
+public enum PlayoutAngleComponents
+{
+ Start = 1,
+ End = 2,
+ All = Start | End
+}
+
+public readonly record struct PlayoutPoint(float X, float Y, float Z = 0);
+
+///
+/// COM-neutral, ordered K3D mutation applied inside the scene transaction.
+/// Concrete mutation types form the allowlist; arbitrary method names are never accepted.
+///
+public abstract record PlayoutMutation;
+
+public enum PlayoutMutationTiming
+{
+ BeforeTransaction,
+ InTransaction
+}
+
+public sealed record PlayoutSetValue(string ObjectName, string Value) : PlayoutMutation;
+
+public sealed record PlayoutSetAssetValue(string ObjectName, string AssetPath) : PlayoutMutation;
+
+public sealed record PlayoutSetVisible(string ObjectName, bool IsVisible) : PlayoutMutation;
+
+public sealed record PlayoutSetFaceColor(
+ string ObjectName,
+ int Red,
+ int Green,
+ int Blue,
+ int Alpha = 255) : PlayoutMutation;
+
+public sealed record PlayoutSetPosition(
+ string ObjectName,
+ float X,
+ float Y,
+ float Z,
+ PlayoutVectorComponents Components) : PlayoutMutation;
+
+public sealed record PlayoutSetPositionKey(
+ string ObjectName,
+ int KeyIndex,
+ float X,
+ float Y,
+ float Z,
+ PlayoutVectorComponents Components) : PlayoutMutation;
+
+public sealed record PlayoutSetScale(
+ string ObjectName,
+ float X,
+ float Y,
+ float Z,
+ PlayoutVectorComponents Components) : PlayoutMutation;
+
+public sealed record PlayoutSetCropKey(
+ string ObjectName,
+ int KeyIndex,
+ float Left,
+ float Top,
+ float Right,
+ float Bottom,
+ PlayoutCropEdges Edges) : PlayoutMutation;
+
+public sealed record PlayoutSetCircleAngleKey(
+ string ObjectName,
+ int KeyIndex,
+ float Start,
+ float End,
+ PlayoutAngleComponents Components) : PlayoutMutation;
+
+public sealed record PlayoutSetPathPoints(
+ string ObjectName,
+ IReadOnlyList Points) : PlayoutMutation;
+
+public sealed record PlayoutSetPathShapePoints(
+ string ObjectName,
+ IReadOnlyList Points) : PlayoutMutation;
+
+public sealed record PlayoutUseBackground(
+ bool IsEnabled,
+ PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
+
+public sealed record PlayoutSetBackgroundTexture(
+ string AssetPath,
+ PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
+
+public sealed record PlayoutSetBackgroundVideo(
+ string AssetPath,
+ int LoopCount,
+ bool LoopInfinite,
+ PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
+
///
/// Identifies a scene and the neutral object changes needed to prepare it.
/// SceneFile is an input only and is never copied into user-facing status/error text.
@@ -75,7 +195,8 @@ public sealed record PlayoutCue(
string SceneFile,
string SceneName,
IReadOnlyList? Fields = null,
- int FadeDuration = 0);
+ int FadeDuration = 0,
+ IReadOnlyList? Mutations = null);
public sealed record PlayoutStatus(
PlayoutMode Mode,
@@ -115,9 +236,9 @@ public sealed record PlayoutStatus(
};
///
- /// The late-bound adapter currently cannot observe the 282-method IKAEventHandler callback
- /// interface without a separately validated interop strategy. Null therefore means that the
- /// OnHello callback was not programmatically verified, not that Tornado rejected the request.
+ /// True after the dynamically generated, vendor-compatible IKAEventHandler receives OnHello;
+ /// false when a completed connection lifecycle explicitly proves it was not observed. Null
+ /// means that callback delivery is still pending or could not be established reliably.
///
public bool? KtapHelloObserved { get; init; }
@@ -185,6 +306,14 @@ public interface IPlayoutEngine : IAsyncDisposable
PlayoutCue cue,
CancellationToken cancellationToken = default);
+ ///
+ /// Updates and replays the scene already on air without loading a replacement scene.
+ /// Used by MainForm-compatible timer refresh through GetPlayingScene.
+ ///
+ Task UpdateOnAirAsync(
+ PlayoutCue cue,
+ CancellationToken cancellationToken = default);
+
Task TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default);
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
index 8bfdd8b..4c5d634 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Text.Json;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Playout;
@@ -14,6 +15,26 @@ public sealed record PlayoutBridgeCommandParseResult(
PlayoutBridgeCommand Request,
string Error);
+public sealed record PlayoutBridgeWorkflowCommand(
+ string RequestId,
+ string Command,
+ IReadOnlyList? Playlist,
+ int SelectedIndexZeroBased);
+
+public sealed record PlayoutBridgeWorkflowCommandParseResult(
+ bool IsValid,
+ PlayoutBridgeWorkflowCommand Request,
+ string Error);
+
+public sealed record PlayoutBridgeTimeoutQuarantine(
+ string RequestId,
+ string Command);
+
+public sealed record PlayoutBridgeTimeoutQuarantineParseResult(
+ bool IsValid,
+ PlayoutBridgeTimeoutQuarantine Request,
+ string Error);
+
///
/// Strict, COM-neutral parser for the local WebView playout command boundary.
/// Presentation text is intentionally not accepted as scene mutation data.
@@ -22,6 +43,8 @@ public static class PlayoutBridgeProtocol
{
private const int MaximumRequestIdLength = 128;
private const int MaximumSceneCodeLength = 64;
+ private const int MaximumPlaylistLength = 1_000;
+ private const int MaximumLookupTextLength = 256;
public static bool IsTrustedSource(string? source, string expectedHost) =>
!string.IsNullOrWhiteSpace(source) &&
@@ -77,6 +100,111 @@ public static class PlayoutBridgeProtocol
return Valid(new PlayoutBridgeCommand(requestId, command, null));
}
+ ///
+ /// Parses the native workflow boundary used by the migrated MainForm state machine.
+ /// PREPARE supplies an immutable playlist snapshot; NEXT has no caller-selected cue so
+ /// native PageN/playlist rules remain authoritative. Lookup text can only reach
+ /// parameterized scene-data loaders and is never interpreted as a K3D control surface.
+ ///
+ public static PlayoutBridgeWorkflowCommandParseResult ParseWorkflowCommand(
+ JsonElement payload)
+ {
+ var empty = new PlayoutBridgeWorkflowCommand(
+ string.Empty,
+ string.Empty,
+ null,
+ -1);
+ if (payload.ValueKind != JsonValueKind.Object ||
+ !HasOnlyProperties(payload, "requestId", "command", "playlist", "selectedIndex"))
+ {
+ return InvalidWorkflow(empty, "The playout workflow request shape is invalid.");
+ }
+
+ if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId))
+ {
+ return InvalidWorkflow(empty, "The playout workflow request identifier is invalid.");
+ }
+
+ var command = GetString(payload, "command") ?? string.Empty;
+ if (command is not ("prepare" or "take-in" or "next" or "take-out"))
+ {
+ return InvalidWorkflow(
+ new PlayoutBridgeWorkflowCommand(requestId, string.Empty, null, -1),
+ "The playout workflow command is not supported.");
+ }
+
+ var hasPlaylist = payload.TryGetProperty("playlist", out var playlistElement);
+ var hasSelectedIndex = payload.TryGetProperty("selectedIndex", out var indexElement);
+ if (command == "prepare")
+ {
+ if (!hasPlaylist || !hasSelectedIndex ||
+ !TryParsePlaylist(playlistElement, out var playlist) ||
+ indexElement.ValueKind != JsonValueKind.Number ||
+ !indexElement.TryGetInt32(out var selectedIndex) ||
+ selectedIndex < 0 || selectedIndex >= playlist!.Count)
+ {
+ return InvalidWorkflow(
+ new PlayoutBridgeWorkflowCommand(requestId, command, null, -1),
+ "The PREPARE playlist selection is invalid.");
+ }
+
+ return ValidWorkflow(new PlayoutBridgeWorkflowCommand(
+ requestId,
+ command,
+ playlist,
+ selectedIndex));
+ }
+
+ if (hasPlaylist || hasSelectedIndex)
+ {
+ return InvalidWorkflow(
+ new PlayoutBridgeWorkflowCommand(requestId, command, null, -1),
+ "Only PREPARE can supply a playlist selection.");
+ }
+
+ return ValidWorkflow(new PlayoutBridgeWorkflowCommand(
+ requestId,
+ command,
+ null,
+ -1));
+ }
+
+ ///
+ /// Parses the browser watchdog notification. A valid notification permanently
+ /// quarantines the native playout runtime for the remainder of the app process;
+ /// it is deliberately not a playout command and carries no scene data.
+ ///
+ public static PlayoutBridgeTimeoutQuarantineParseResult ParseTimeoutQuarantine(
+ JsonElement payload)
+ {
+ var empty = new PlayoutBridgeTimeoutQuarantine(string.Empty, string.Empty);
+ if (payload.ValueKind != JsonValueKind.Object ||
+ !HasOnlyProperties(payload, "requestId", "command"))
+ {
+ return InvalidTimeoutQuarantine(
+ empty,
+ "The playout timeout quarantine request shape is invalid.");
+ }
+
+ if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId))
+ {
+ return InvalidTimeoutQuarantine(
+ empty,
+ "The playout timeout quarantine request identifier is invalid.");
+ }
+
+ var command = GetString(payload, "command") ?? string.Empty;
+ if (command is not ("prepare" or "take-in" or "next" or "take-out"))
+ {
+ return InvalidTimeoutQuarantine(
+ new PlayoutBridgeTimeoutQuarantine(requestId, string.Empty),
+ "The timed-out playout command is not supported.");
+ }
+
+ return ValidTimeoutQuarantine(
+ new PlayoutBridgeTimeoutQuarantine(requestId, command));
+ }
+
private static bool TryParseCue(JsonElement payload, out PlayoutCue? cue)
{
cue = null;
@@ -91,6 +219,107 @@ public static class PlayoutBridgeProtocol
return true;
}
+ private static bool TryParsePlaylist(
+ JsonElement payload,
+ out IReadOnlyList? playlist)
+ {
+ playlist = null;
+ if (payload.ValueKind != JsonValueKind.Array ||
+ payload.GetArrayLength() is < 1 or > MaximumPlaylistLength)
+ {
+ return false;
+ }
+
+ var entries = new List(payload.GetArrayLength());
+ var identifiers = new HashSet(StringComparer.Ordinal);
+ foreach (var element in payload.EnumerateArray())
+ {
+ if (element.ValueKind != JsonValueKind.Object ||
+ !HasOnlyProperties(element, "id", "code", "enabled", "fadeDuration", "selection") ||
+ !TryGetSafeToken(element, "id", MaximumRequestIdLength, out var id) ||
+ !TryGetSafeToken(element, "code", MaximumSceneCodeLength, out var code) ||
+ !identifiers.Add(id) ||
+ !element.TryGetProperty("enabled", out var enabledElement) ||
+ enabledElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ {
+ return false;
+ }
+
+ int? fadeDuration = null;
+ if (element.TryGetProperty("fadeDuration", out var fadeElement))
+ {
+ if (fadeElement.ValueKind != JsonValueKind.Number ||
+ !fadeElement.TryGetInt32(out var parsedFade) ||
+ parsedFade is < 0 or > 60)
+ {
+ return false;
+ }
+
+ fadeDuration = parsedFade;
+ }
+
+ LegacySceneSelection? selection = null;
+ if (element.TryGetProperty("selection", out var selectionElement) &&
+ !TryParseSelection(selectionElement, out selection))
+ {
+ return false;
+ }
+
+ entries.Add(new LegacyPlaylistEntry(
+ id,
+ code,
+ enabledElement.GetBoolean(),
+ fadeDuration,
+ selection));
+ }
+
+ playlist = Array.AsReadOnly(entries.ToArray());
+ return true;
+ }
+
+ private static bool TryParseSelection(
+ JsonElement payload,
+ out LegacySceneSelection? selection)
+ {
+ selection = null;
+ if (payload.ValueKind != JsonValueKind.Object ||
+ !HasOnlyProperties(
+ payload,
+ "groupCode",
+ "subject",
+ "graphicType",
+ "subtype",
+ "dataCode") ||
+ !TryGetLookupText(payload, "groupCode", out var groupCode) ||
+ !TryGetLookupText(payload, "subject", out var subject) ||
+ !TryGetLookupText(payload, "graphicType", out var graphicType) ||
+ !TryGetLookupText(payload, "subtype", out var subtype) ||
+ !TryGetLookupText(payload, "dataCode", out var dataCode))
+ {
+ return false;
+ }
+
+ selection = new LegacySceneSelection(
+ groupCode,
+ subject,
+ graphicType,
+ subtype,
+ dataCode);
+ return true;
+ }
+
+ private static bool TryGetLookupText(
+ JsonElement payload,
+ string propertyName,
+ out string value)
+ {
+ value = GetString(payload, propertyName) ?? string.Empty;
+ return payload.TryGetProperty(propertyName, out var element) &&
+ element.ValueKind == JsonValueKind.String &&
+ value.Length <= MaximumLookupTextLength &&
+ !value.Any(char.IsControl);
+ }
+
private static bool TryGetSafeToken(
JsonElement payload,
string propertyName,
@@ -128,4 +357,18 @@ public static class PlayoutBridgeProtocol
private static PlayoutBridgeCommandParseResult Invalid(
PlayoutBridgeCommand request,
string error) => new(false, request, error);
+
+ private static PlayoutBridgeWorkflowCommandParseResult ValidWorkflow(
+ PlayoutBridgeWorkflowCommand request) => new(true, request, string.Empty);
+
+ private static PlayoutBridgeWorkflowCommandParseResult InvalidWorkflow(
+ PlayoutBridgeWorkflowCommand request,
+ string error) => new(false, request, error);
+
+ private static PlayoutBridgeTimeoutQuarantineParseResult ValidTimeoutQuarantine(
+ PlayoutBridgeTimeoutQuarantine request) => new(true, request, string.Empty);
+
+ private static PlayoutBridgeTimeoutQuarantineParseResult InvalidTimeoutQuarantine(
+ PlayoutBridgeTimeoutQuarantine request,
+ string error) => new(false, request, error);
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs
new file mode 100644
index 0000000..166ff6f
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs
@@ -0,0 +1,331 @@
+#nullable enable
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout;
+
+///
+/// The only page sizes used by the legacy PageN/Nxt_PageN workflow.
+///
+public enum ScenePageSize
+{
+ Five = 5,
+ Six = 6,
+ Twelve = 12
+}
+
+public enum ScenePagingResultKind
+{
+ Valid,
+ Invalid
+}
+
+///
+/// A validated paging plan. Page numbers are one-based; item offsets are zero-based.
+/// Items beyond pages are deliberately excluded.
+///
+public sealed record ScenePagingPlan
+{
+ internal ScenePagingPlan(
+ int itemCount,
+ ScenePageSize pageSize,
+ int totalPages,
+ int accessibleItemCount)
+ {
+ ItemCount = itemCount;
+ PageSize = pageSize;
+ TotalPages = totalPages;
+ AccessibleItemCount = accessibleItemCount;
+ }
+
+ public int ItemCount { get; }
+
+ public ScenePageSize PageSize { get; }
+
+ public int TotalPages { get; }
+
+ public int AccessibleItemCount { get; }
+
+ public int ExcludedItemCount => ItemCount - AccessibleItemCount;
+}
+
+public sealed record ScenePagingPlanResult(
+ ScenePagingResultKind Kind,
+ ScenePagingPlan? Plan,
+ string Error)
+{
+ public bool IsValid => Kind == ScenePagingResultKind.Valid;
+}
+
+///
+/// Describes one valid page. PageNumberOneBased is one-based; OffsetZeroBased is zero-based.
+/// RemainingItemCount is limited to the accessible 20-page range.
+///
+public sealed record ScenePageWindow(
+ int PageNumberOneBased,
+ int TotalPages,
+ int OffsetZeroBased,
+ int ItemCount,
+ int RemainingItemCount);
+
+public sealed record ScenePageWindowResult(
+ ScenePagingResultKind Kind,
+ ScenePagingPlan? Plan,
+ ScenePageWindow? Window,
+ string Error)
+{
+ public bool IsValid => Kind == ScenePagingResultKind.Valid;
+}
+
+///
+/// A playlist definition. ItemCount must be zero for a non-paged cue.
+///
+public sealed record ScenePlaylistCue(
+ string CueCode,
+ bool IsEnabled,
+ ScenePageSize? PageSize = null,
+ int ItemCount = 0);
+
+///
+/// Runtime state used to decide NEXT. CurrentCueIndexZeroBased is zero-based and
+/// CurrentPageNumberOneBased is one-based. A non-paged cue, or a paged cue with no
+/// available items, uses a null current page.
+///
+public sealed record SceneNextState(
+ IReadOnlyList? Playlist,
+ int CurrentCueIndexZeroBased,
+ int? CurrentPageNumberOneBased);
+
+public enum SceneNextDecisionKind
+{
+ SameCueNextPage,
+ NextEnabledCue,
+ EndOfPlaylist,
+ Invalid
+}
+
+public sealed record SceneNextDecision(
+ SceneNextDecisionKind Kind,
+ int? TargetCueIndexZeroBased,
+ int? TargetPageNumberOneBased,
+ ScenePageWindow? TargetPage,
+ string Error)
+{
+ public bool IsValid => Kind != SceneNextDecisionKind.Invalid;
+}
+
+///
+/// Pure PageN/Nxt_PageN calculations with no database, COM, or UI dependency.
+///
+public static class ScenePaging
+{
+ public const int MaximumPageCount = 20;
+
+ public static ScenePagingPlanResult CreatePlan(
+ int itemCount,
+ ScenePageSize pageSize)
+ {
+ if (itemCount < 0)
+ {
+ return InvalidPlan("ItemCount cannot be negative.");
+ }
+
+ if (!IsSupported(pageSize))
+ {
+ return InvalidPlan("PageSize must be 5, 6, or 12.");
+ }
+
+ var size = (int)pageSize;
+ var uncappedTotalPages = itemCount == 0
+ ? 0
+ : ((itemCount - 1) / size) + 1;
+ var totalPages = Math.Min(MaximumPageCount, uncappedTotalPages);
+ var accessibleItemCount = Math.Min(itemCount, MaximumPageCount * size);
+
+ return new ScenePagingPlanResult(
+ ScenePagingResultKind.Valid,
+ new ScenePagingPlan(itemCount, pageSize, totalPages, accessibleItemCount),
+ string.Empty);
+ }
+
+ public static ScenePageWindowResult GetWindow(
+ int itemCount,
+ ScenePageSize pageSize,
+ int pageNumberOneBased)
+ {
+ var planResult = CreatePlan(itemCount, pageSize);
+ if (!planResult.IsValid || planResult.Plan is null)
+ {
+ return new ScenePageWindowResult(
+ ScenePagingResultKind.Invalid,
+ null,
+ null,
+ planResult.Error);
+ }
+
+ var plan = planResult.Plan;
+ if (pageNumberOneBased < 1 || pageNumberOneBased > plan.TotalPages)
+ {
+ return new ScenePageWindowResult(
+ ScenePagingResultKind.Invalid,
+ plan,
+ null,
+ "PageNumberOneBased is outside the available page range.");
+ }
+
+ var size = (int)plan.PageSize;
+ var offset = (pageNumberOneBased - 1) * size;
+ var count = Math.Min(size, plan.AccessibleItemCount - offset);
+ var remaining = plan.AccessibleItemCount - offset - count;
+ var window = new ScenePageWindow(
+ pageNumberOneBased,
+ plan.TotalPages,
+ offset,
+ count,
+ remaining);
+
+ return new ScenePageWindowResult(
+ ScenePagingResultKind.Valid,
+ plan,
+ window,
+ string.Empty);
+ }
+
+ public static SceneNextDecision DecideNext(SceneNextState? state)
+ {
+ if (state?.Playlist is null)
+ {
+ return InvalidDecision("Playlist state is required.");
+ }
+
+ var playlist = state.Playlist;
+ if (state.CurrentCueIndexZeroBased < 0 ||
+ state.CurrentCueIndexZeroBased >= playlist.Count)
+ {
+ return InvalidDecision("CurrentCueIndexZeroBased is outside the playlist.");
+ }
+
+ var plans = new ScenePagingPlan?[playlist.Count];
+ for (var index = 0; index < playlist.Count; index++)
+ {
+ var cue = playlist[index];
+ if (cue is null || string.IsNullOrWhiteSpace(cue.CueCode))
+ {
+ return InvalidDecision("Every playlist cue requires a cue code.");
+ }
+
+ if (cue.PageSize is null)
+ {
+ if (cue.ItemCount != 0)
+ {
+ return InvalidDecision("A non-paged cue must have ItemCount zero.");
+ }
+
+ continue;
+ }
+
+ var planResult = CreatePlan(cue.ItemCount, cue.PageSize.Value);
+ if (!planResult.IsValid || planResult.Plan is null)
+ {
+ return InvalidDecision(planResult.Error);
+ }
+
+ plans[index] = planResult.Plan;
+ }
+
+ var currentIndex = state.CurrentCueIndexZeroBased;
+ var currentCue = playlist[currentIndex];
+ var currentPlan = plans[currentIndex];
+
+ if (currentPlan is null)
+ {
+ if (state.CurrentPageNumberOneBased is not null)
+ {
+ return InvalidDecision("A non-paged cue cannot have a current page.");
+ }
+ }
+ else if (currentPlan.TotalPages == 0)
+ {
+ if (state.CurrentPageNumberOneBased is not null)
+ {
+ return InvalidDecision("A paged cue with no items cannot have a current page.");
+ }
+ }
+ else
+ {
+ var currentPage = state.CurrentPageNumberOneBased;
+ if (currentPage is null ||
+ currentPage.Value < 1 ||
+ currentPage.Value > currentPlan.TotalPages)
+ {
+ return InvalidDecision("CurrentPageNumberOneBased is outside the current cue.");
+ }
+
+ if (currentPage.Value < currentPlan.TotalPages)
+ {
+ var nextPage = currentPage.Value + 1;
+ var pageResult = GetWindow(
+ currentCue.ItemCount,
+ currentCue.PageSize!.Value,
+ nextPage);
+ if (!pageResult.IsValid || pageResult.Window is null)
+ {
+ return InvalidDecision(pageResult.Error);
+ }
+
+ return new SceneNextDecision(
+ SceneNextDecisionKind.SameCueNextPage,
+ currentIndex,
+ nextPage,
+ pageResult.Window,
+ string.Empty);
+ }
+ }
+
+ for (var index = currentIndex + 1; index < playlist.Count; index++)
+ {
+ if (!playlist[index].IsEnabled)
+ {
+ continue;
+ }
+
+ var targetPlan = plans[index];
+ int? targetPageNumber = targetPlan?.TotalPages > 0 ? 1 : null;
+ ScenePageWindow? targetPage = null;
+ if (targetPageNumber is not null)
+ {
+ var pageResult = GetWindow(
+ playlist[index].ItemCount,
+ playlist[index].PageSize!.Value,
+ targetPageNumber.Value);
+ if (!pageResult.IsValid || pageResult.Window is null)
+ {
+ return InvalidDecision(pageResult.Error);
+ }
+
+ targetPage = pageResult.Window;
+ }
+
+ return new SceneNextDecision(
+ SceneNextDecisionKind.NextEnabledCue,
+ index,
+ targetPageNumber,
+ targetPage,
+ string.Empty);
+ }
+
+ return new SceneNextDecision(
+ SceneNextDecisionKind.EndOfPlaylist,
+ null,
+ null,
+ null,
+ string.Empty);
+ }
+
+ private static bool IsSupported(ScenePageSize pageSize) =>
+ pageSize is ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve;
+
+ private static ScenePagingPlanResult InvalidPlan(string error) =>
+ new(ScenePagingResultKind.Invalid, null, error);
+
+ private static SceneNextDecision InvalidDecision(string error) =>
+ new(SceneNextDecisionKind.Invalid, null, null, null, error);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs
new file mode 100644
index 0000000..621f774
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs
@@ -0,0 +1,703 @@
+#nullable enable
+
+using System.Globalization;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public enum CandleCutAlias
+{
+ Cut8035 = 8035,
+ Cut8061 = 8061,
+ Cut8040 = 8040,
+ Cut8046 = 8046,
+ Cut8051 = 8051,
+ Cut8056 = 8056
+}
+
+public enum CandlePeriod
+{
+ Intraday,
+ FiveDays,
+ TwentyDays,
+ SixtyDays,
+ OneHundredTwentyDays,
+ TwoHundredFortyDays
+}
+
+public enum CandleChartMode
+{
+ Price,
+ PriceWithVolume,
+ ExpectedExecution,
+ TradingHalt
+}
+
+public enum CandleMarketTarget
+{
+ KospiIndex,
+ KosdaqIndex,
+ Kospi200Index,
+ Krx100Index,
+ FuturesIndex,
+ KospiIndustry,
+ KosdaqIndustry,
+ OverseasIndex,
+ OverseasStock,
+ KospiStock,
+ KosdaqStock
+}
+
+public static class CandleCutAliasMetadata
+{
+ public static CandlePeriod GetPeriod(CandleCutAlias alias) => alias switch
+ {
+ CandleCutAlias.Cut8035 => CandlePeriod.Intraday,
+ CandleCutAlias.Cut8061 => CandlePeriod.FiveDays,
+ CandleCutAlias.Cut8040 => CandlePeriod.TwentyDays,
+ CandleCutAlias.Cut8046 => CandlePeriod.SixtyDays,
+ CandleCutAlias.Cut8051 => CandlePeriod.OneHundredTwentyDays,
+ CandleCutAlias.Cut8056 => CandlePeriod.TwoHundredFortyDays,
+ _ => throw new LegacySceneDataException("Unknown candle cut alias.")
+ };
+
+ internal static double GetMovingAverageWidth(CandleCutAlias alias) => alias switch
+ {
+ CandleCutAlias.Cut8035 => 0d,
+ CandleCutAlias.Cut8061 => 1188d,
+ CandleCutAlias.Cut8040 => 1398d,
+ CandleCutAlias.Cut8046 => 1442d,
+ CandleCutAlias.Cut8051 => 1452d,
+ CandleCutAlias.Cut8056 => 1464d,
+ _ => throw new LegacySceneDataException("Unknown candle cut alias.")
+ };
+}
+
+public sealed record CandleQuoteData(
+ double CurrentPrice,
+ double ChangePrice,
+ double Rate,
+ ScenePriceDirection Direction);
+
+public sealed record CandleRowData(
+ DateOnly TradingDate,
+ TimeOnly? TradingTime,
+ double Open,
+ double High,
+ double Low,
+ double Close,
+ double? Volume = null,
+ double? MovingAverage5 = null,
+ double? MovingAverage20 = null);
+
+public sealed record S8010SceneData(
+ CandleCutAlias CutAlias,
+ CandleMarketTarget MarketTarget,
+ CandleChartMode ChartMode,
+ string DisplayName,
+ CandleQuoteData Quote,
+ IReadOnlyList Rows,
+ bool ShowMovingAverage5,
+ bool ShowMovingAverage20) : ILegacySceneData;
+
+public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ private const int MaximumCandleCount = 240;
+ private const double GraphStartX = -730.57d;
+ private const double GraphEndX = 738.65d;
+ private const double TagMinimumX = -657.57d;
+ private const double TagMaximumX = 670.65d;
+
+ public string BuilderKey => "s8010";
+
+ public IReadOnlyList Build(S8010SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ ValidateEnums(data);
+ var displayName = LegacySceneBuilderGuard.Text(
+ data.DisplayName,
+ nameof(data.DisplayName));
+ var quote = LegacySceneBuilderGuard.NotNull(data.Quote, nameof(data.Quote));
+ ValidateQuote(quote);
+ var rows = NormalizeRows(data.Rows, data.CutAlias, data.ChartMode);
+ var period = CandleCutAliasMetadata.GetPeriod(data.CutAlias);
+ var lineWidth = CandleCutAliasMetadata.GetMovingAverageWidth(data.CutAlias);
+ var format = UsesIntegerPrices(data.MarketTarget) ? "#,##0" : "#,##0.00";
+ var mutations = new List(540 + (rows.Count * 12))
+ {
+ new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)),
+ // A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
+ new PlayoutSetPathPoints("ma5", Array.Empty()),
+ new PlayoutSetPathPoints("ma20", Array.Empty())
+ };
+
+ AddResolvedTitleAndUnit(
+ mutations,
+ displayName,
+ data.MarketTarget,
+ data.ChartMode);
+ AddQuote(mutations, quote, format);
+
+ var range = CreateRange(rows, data);
+ AddInitialVisibility(mutations, data.ChartMode);
+ var paths = AddCandles(
+ mutations,
+ rows,
+ data,
+ period,
+ lineWidth,
+ format,
+ range);
+ AddMovingAveragePaths(mutations, paths, data);
+ return mutations;
+ }
+
+ private static void ValidateEnums(S8010SceneData data)
+ {
+ if (!Enum.IsDefined(data.CutAlias) || !Enum.IsDefined(data.MarketTarget) ||
+ !Enum.IsDefined(data.ChartMode))
+ {
+ throw new LegacySceneDataException("Unknown candle scene selection.");
+ }
+
+ if (data.ChartMode == CandleChartMode.TradingHalt &&
+ data.MarketTarget is not (CandleMarketTarget.KospiStock or
+ CandleMarketTarget.KosdaqStock))
+ {
+ throw new LegacySceneDataException(
+ "Trading-halt candles require a domestic stock target.");
+ }
+ }
+
+ private static void ValidateQuote(CandleQuoteData quote)
+ {
+ if (!double.IsFinite(quote.CurrentPrice) ||
+ !double.IsFinite(quote.ChangePrice) ||
+ !double.IsFinite(quote.Rate) ||
+ !Enum.IsDefined(quote.Direction))
+ {
+ throw new LegacySceneDataException("Candle quote data is invalid.");
+ }
+ }
+
+ private static IReadOnlyList NormalizeRows(
+ IReadOnlyList? rows,
+ CandleCutAlias alias,
+ CandleChartMode chartMode)
+ {
+ var values = LegacySceneBuilderGuard.Range(
+ rows,
+ 1,
+ MaximumCandleCount,
+ nameof(rows));
+ var requiresTime = CandleCutAliasMetadata.GetPeriod(alias) == CandlePeriod.Intraday;
+ return values.Select((row, index) =>
+ {
+ var value = LegacySceneBuilderGuard.NotNull(row, $"Rows[{index}]");
+ if (requiresTime && value.TradingTime is null)
+ {
+ throw new LegacySceneDataException(
+ $"Scene data field 'Rows[{index}].TradingTime' is required for 8035.");
+ }
+
+ if (!double.IsFinite(value.Open) || !double.IsFinite(value.High) ||
+ !double.IsFinite(value.Low) || !double.IsFinite(value.Close) ||
+ value.High < value.Low || value.High < value.Open ||
+ value.High < value.Close || value.Low > value.Open ||
+ value.Low > value.Close)
+ {
+ throw new LegacySceneDataException(
+ $"Scene data field 'Rows[{index}]' has an invalid OHLC range.");
+ }
+
+ ValidateOptionalFinite(value.Volume, $"Rows[{index}].Volume");
+ ValidateOptionalFinite(value.MovingAverage5, $"Rows[{index}].MovingAverage5");
+ ValidateOptionalFinite(value.MovingAverage20, $"Rows[{index}].MovingAverage20");
+ if (value.Volume < 0)
+ {
+ throw new LegacySceneDataException(
+ $"Scene data field 'Rows[{index}].Volume' cannot be negative.");
+ }
+
+ if (chartMode == CandleChartMode.PriceWithVolume && value.Volume is null)
+ {
+ // The vendor source treats DB null as zero. Normalize it before calculating max.
+ return value with { Volume = 0d };
+ }
+
+ return value;
+ }).ToArray();
+ }
+
+ private static void ValidateOptionalFinite(double? value, string name)
+ {
+ if (value.HasValue && !double.IsFinite(value.Value))
+ {
+ throw new LegacySceneDataException($"Scene data field '{name}' must be finite.");
+ }
+ }
+
+ private static string InitialTitle(string displayName, CandleChartMode mode) =>
+ mode == CandleChartMode.ExpectedExecution
+ ? displayName + " \uC608\uC0C1\uCCB4\uACB0"
+ : displayName;
+
+ private static void AddResolvedTitleAndUnit(
+ ICollection mutations,
+ string displayName,
+ CandleMarketTarget target,
+ CandleChartMode mode)
+ {
+ if (target == CandleMarketTarget.OverseasStock)
+ {
+ mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : \uB2EC\uB7EC"));
+ return;
+ }
+
+ if (target == CandleMarketTarget.OverseasIndex)
+ {
+ mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"));
+ return;
+ }
+
+ if (IsIndex(target))
+ {
+ var title = MarketLabel(target) + (mode == CandleChartMode.ExpectedExecution
+ ? " \uC608\uC0C1\uCCB4\uACB0\uC9C0\uC218"
+ : " \uC9C0\uC218");
+ mutations.Add(new PlayoutSetValue("title", title));
+ mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"));
+ return;
+ }
+
+ if (mode == CandleChartMode.ExpectedExecution)
+ {
+ // The legacy non-index expected-price branch only rewrites the title.
+ mutations.Add(new PlayoutSetValue(
+ "title",
+ displayName + " \uC608\uC0C1\uCCB4\uACB0"));
+ return;
+ }
+
+ var resolvedTitle = target switch
+ {
+ CandleMarketTarget.KospiIndustry => "\uCF54\uC2A4\uD53C - " + displayName,
+ CandleMarketTarget.KosdaqIndustry => "\uCF54\uC2A4\uB2E5 - " + displayName,
+ _ => displayName
+ };
+ var unit = IsIndustry(target) ? "\uB2E8\uC704 : p" : "\uB2E8\uC704 : \uC6D0";
+ // The legacy stock/industry branch writes unit before its resolved title.
+ mutations.Add(new PlayoutSetValue("unit", unit));
+ mutations.Add(new PlayoutSetValue("title", resolvedTitle));
+ }
+
+ private static string MarketLabel(CandleMarketTarget target) => target switch
+ {
+ CandleMarketTarget.KospiIndex => "\uCF54\uC2A4\uD53C",
+ CandleMarketTarget.KosdaqIndex => "\uCF54\uC2A4\uB2E5",
+ CandleMarketTarget.Kospi200Index => "\uCF54\uC2A4\uD53C200",
+ CandleMarketTarget.Krx100Index => "KRX100",
+ CandleMarketTarget.FuturesIndex => "\uC120\uBB3C",
+ _ => throw new LegacySceneDataException("The target is not a domestic index.")
+ };
+
+ private static void AddQuote(
+ ICollection mutations,
+ CandleQuoteData quote,
+ string format)
+ {
+ mutations.Add(new PlayoutSetValue(
+ "price",
+ quote.CurrentPrice.ToString(format, CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "changePrice",
+ quote.ChangePrice.ToString(format, CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "rate",
+ quote.Rate.ToString("##0.00", CultureInfo.InvariantCulture)));
+ CandleQuoteDirection.Add(mutations, quote.Direction);
+ }
+
+ private static CandleRange CreateRange(
+ IReadOnlyList rows,
+ S8010SceneData data)
+ {
+ var candleMinimum = rows.Min(row => row.Low);
+ var candleMaximum = rows.Max(row => row.High);
+ var minimum = candleMinimum;
+ var maximum = candleMaximum;
+ if (SupportsMovingAverages(data.MarketTarget))
+ {
+ if (data.ShowMovingAverage5)
+ {
+ var values = rows.Where(row => row.MovingAverage5.HasValue)
+ .Select(row => row.MovingAverage5!.Value)
+ .ToArray();
+ if (values.Length != 0)
+ {
+ minimum = Math.Min(minimum, values.Min());
+ maximum = Math.Max(maximum, values.Max());
+ }
+ }
+
+ if (data.ShowMovingAverage20)
+ {
+ var values = rows.Where(row => row.MovingAverage20.HasValue)
+ .Select(row => row.MovingAverage20!.Value)
+ .ToArray();
+ if (values.Length != 0)
+ {
+ minimum = Math.Min(minimum, values.Min());
+ maximum = Math.Max(maximum, values.Max());
+ }
+ }
+ }
+
+ var gap = maximum - minimum;
+ if (!double.IsFinite(gap))
+ {
+ throw new LegacySceneDataException(
+ "Candle price range cannot be represented as a finite value.");
+ }
+
+ if (gap == 0d)
+ {
+ // Equal OHLC input formerly divided by zero. A unit range keeps every crop finite.
+ gap = 1d;
+ }
+
+ var maximumVolume = data.ChartMode == CandleChartMode.PriceWithVolume
+ ? rows.Max(row => row.Volume ?? 0d)
+ : 0d;
+ return new CandleRange(
+ minimum,
+ maximum,
+ gap,
+ candleMinimum,
+ candleMaximum,
+ maximumVolume);
+ }
+
+ private static void AddInitialVisibility(
+ ICollection mutations,
+ CandleChartMode mode)
+ {
+ mutations.Add(new PlayoutSetVisible(
+ "volrumeG",
+ mode == CandleChartMode.PriceWithVolume));
+ for (var index = 1; index <= MaximumCandleCount; index++)
+ {
+ mutations.Add(new PlayoutSetVisible("volume" + index, false));
+ }
+
+ for (var index = 1; index <= MaximumCandleCount; index++)
+ {
+ mutations.Add(new PlayoutSetVisible("candle" + index, false));
+ }
+ }
+
+ private static CandlePaths AddCandles(
+ ICollection mutations,
+ IReadOnlyList rows,
+ S8010SceneData data,
+ CandlePeriod period,
+ double lineWidth,
+ string format,
+ CandleRange range)
+ {
+ var ma5Points = new List(rows.Count);
+ var ma20Points = new List(rows.Count);
+ var needMovingAverageChecker = false;
+ var movingAverageCounter = 0;
+ var originalGraphLength = GraphEndX - GraphStartX;
+ var endX = GraphEndX;
+ var tagMaximum = TagMaximumX;
+ var length = originalGraphLength;
+ var lengthPerCandle = length / rows.Count;
+ if (period == CandlePeriod.Intraday)
+ {
+ endX = (originalGraphLength * rows.Count / 78d) + GraphStartX;
+ tagMaximum = endX - 68d;
+ length = endX - GraphStartX;
+ lengthPerCandle = length / rows.Count;
+ }
+
+ for (var zeroBased = 0; zeroBased < rows.Count; zeroBased++)
+ {
+ var row = rows[zeroBased];
+ var index = zeroBased + 1;
+ AddDateLabel(mutations, rows, period, zeroBased);
+
+ if (row.High.Equals(range.CandleMaximum))
+ {
+ var x = TagPosition(index, lengthPerCandle, tagMaximum);
+ mutations.Add(new PlayoutSetPosition(
+ "highTag", (float)x, 0, 0, PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "highPrice", row.High.ToString(format, CultureInfo.InvariantCulture)));
+ }
+
+ if (row.Low.Equals(range.CandleMinimum))
+ {
+ var x = TagPosition(index, lengthPerCandle, tagMaximum);
+ mutations.Add(new PlayoutSetPosition(
+ "lowTag", (float)x, 0, 0, PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "lowPrice", row.Low.ToString(format, CultureInfo.InvariantCulture)));
+ }
+
+ mutations.Add(new PlayoutSetVisible("candle" + index, true));
+ mutations.Add(Crop(
+ "candlebar" + index,
+ Normalize(row.High, range),
+ Normalize(row.Low, range)));
+
+ if (row.MovingAverage5.HasValue)
+ {
+ var include = true;
+ if (needMovingAverageChecker)
+ {
+ movingAverageCounter++;
+ include = movingAverageCounter >= 4;
+ }
+
+ if (include)
+ {
+ ma5Points.Add(MovingAveragePoint(
+ row.MovingAverage5.Value,
+ zeroBased,
+ rows.Count,
+ lineWidth,
+ range));
+ }
+ }
+ else
+ {
+ needMovingAverageChecker = true;
+ }
+
+ if (row.MovingAverage20.HasValue &&
+ (!needMovingAverageChecker || movingAverageCounter >= 19))
+ {
+ ma20Points.Add(MovingAveragePoint(
+ row.MovingAverage20.Value,
+ zeroBased,
+ rows.Count,
+ lineWidth,
+ range));
+ }
+
+ AddCandleBody(mutations, row, index, range);
+ if (data.ChartMode == CandleChartMode.PriceWithVolume)
+ {
+ var volume = row.Volume ?? 0d;
+ var percentage = range.MaximumVolume == 0d
+ ? 0d
+ : Math.Abs(volume / range.MaximumVolume * 100d);
+ mutations.Add(new PlayoutSetVisible("volume" + index, true));
+ mutations.Add(Crop("volume" + index, 100d - percentage, 100d));
+ }
+ }
+
+ return new CandlePaths(ma5Points, ma20Points);
+ }
+
+ private static void AddDateLabel(
+ ICollection mutations,
+ IReadOnlyList rows,
+ CandlePeriod period,
+ int zeroBasedIndex)
+ {
+ if (period == CandlePeriod.Intraday)
+ {
+ if (zeroBasedIndex != 0)
+ {
+ return;
+ }
+
+ var startsAtNine = rows[0].TradingTime!.Value.Hour == 9;
+ mutations.Add(new PlayoutSetValue("date_begin", startsAtNine ? "09:00" : "10:00"));
+ mutations.Add(new PlayoutSetValue("date_middle", startsAtNine ? "12:00" : "13:00"));
+ mutations.Add(new PlayoutSetValue("date_end", startsAtNine ? "15:30" : "16:30"));
+ return;
+ }
+
+ var index = zeroBasedIndex + 1;
+ var value = rows[zeroBasedIndex].TradingDate.ToString("MM/dd", CultureInfo.InvariantCulture);
+ if (index == 1)
+ {
+ mutations.Add(new PlayoutSetValue("date_begin", value));
+ }
+ else if (index == rows.Count)
+ {
+ mutations.Add(new PlayoutSetValue("date_end", value));
+ }
+ else if (index == (int)Math.Ceiling(rows.Count / 2d))
+ {
+ mutations.Add(new PlayoutSetValue("date_middle", value));
+ }
+ }
+
+ private static double TagPosition(
+ int oneBasedIndex,
+ double lengthPerCandle,
+ double tagMaximum)
+ {
+ var x = GraphStartX + (oneBasedIndex * lengthPerCandle) - (lengthPerCandle / 2d);
+ if (x > tagMaximum)
+ {
+ x = tagMaximum;
+ }
+ else if (x < TagMinimumX)
+ {
+ x = TagMinimumX;
+ }
+
+ return x;
+ }
+
+ private static double Normalize(double value, CandleRange range) =>
+ Math.Abs((value - range.Maximum) / range.Gap * 100d);
+
+ private static PlayoutSetCropKey Crop(string objectName, double top, double bottom) =>
+ new(
+ objectName,
+ 1,
+ 0,
+ (float)top,
+ 0,
+ (float)bottom,
+ PlayoutCropEdges.Vertical);
+
+ private static PlayoutPoint MovingAveragePoint(
+ double value,
+ int zeroBasedIndex,
+ int count,
+ double lineWidth,
+ CandleRange range)
+ {
+ var x = count <= 1
+ ? 0f
+ : (float)(lineWidth / (count - 1d)) * zeroBasedIndex;
+ var y = (float)((value - range.Maximum) / range.Gap * 196d) + 98f;
+ return new PlayoutPoint(x, y, 0);
+ }
+
+ private static void AddCandleBody(
+ ICollection mutations,
+ CandleRowData row,
+ int index,
+ CandleRange range)
+ {
+ var open = Normalize(row.Open, range);
+ var close = Normalize(row.Close, range);
+ var bodyName = "candlebody" + index;
+ var barName = "candlebar" + index;
+ if (row.Open > row.Close)
+ {
+ mutations.Add(Crop(bodyName, open, close));
+ mutations.Add(new PlayoutSetFaceColor(bodyName, 15, 99, 189));
+ mutations.Add(new PlayoutSetFaceColor(barName, 15, 99, 189));
+ }
+ else if (row.Open < row.Close)
+ {
+ mutations.Add(Crop(bodyName, close, open));
+ mutations.Add(new PlayoutSetFaceColor(bodyName, 170, 0, 0));
+ mutations.Add(new PlayoutSetFaceColor(barName, 170, 0, 0));
+ }
+ else
+ {
+ mutations.Add(Crop(bodyName, close, open + 1d));
+ mutations.Add(new PlayoutSetFaceColor(bodyName, 95, 95, 95));
+ mutations.Add(new PlayoutSetFaceColor(barName, 95, 95, 95));
+ }
+ }
+
+ private static void AddMovingAveragePaths(
+ ICollection mutations,
+ CandlePaths paths,
+ S8010SceneData data)
+ {
+ mutations.Add(new PlayoutSetPathPoints(
+ "ma5",
+ data.ShowMovingAverage5 ? paths.MovingAverage5 : Array.Empty()));
+ mutations.Add(new PlayoutSetPathPoints(
+ "ma20",
+ data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty()));
+ mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
+
+ if (!SupportsMovingAverages(data.MarketTarget))
+ {
+ mutations.Add(new PlayoutSetVisible("guide", false));
+ mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty()));
+ mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty()));
+ }
+ }
+
+ private static bool UsesIntegerPrices(CandleMarketTarget target) => target is
+ CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock;
+
+ private static bool IsIndex(CandleMarketTarget target) => target is
+ CandleMarketTarget.KospiIndex or CandleMarketTarget.KosdaqIndex or
+ CandleMarketTarget.Kospi200Index or CandleMarketTarget.Krx100Index or
+ CandleMarketTarget.FuturesIndex;
+
+ private static bool IsIndustry(CandleMarketTarget target) => target is
+ CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry;
+
+ private static bool SupportsMovingAverages(CandleMarketTarget target) =>
+ !IsIndex(target) && !IsIndustry(target) && target != CandleMarketTarget.OverseasIndex;
+
+ private sealed record CandleRange(
+ double Minimum,
+ double Maximum,
+ double Gap,
+ double CandleMinimum,
+ double CandleMaximum,
+ double MaximumVolume);
+
+ private sealed record CandlePaths(
+ IReadOnlyList MovingAverage5,
+ IReadOnlyList MovingAverage20);
+}
+
+internal static class CandleQuoteDirection
+{
+ private static readonly string[] DirectionObjects =
+ ["upup", "up", "flat", "down", "downdown"];
+
+ public static void Add(
+ ICollection mutations,
+ ScenePriceDirection direction)
+ {
+ foreach (var name in DirectionObjects)
+ {
+ mutations.Add(new PlayoutSetVisible(name, false));
+ }
+
+ string? active = direction switch
+ {
+ ScenePriceDirection.LimitUp => "upup",
+ ScenePriceDirection.Up => "up",
+ ScenePriceDirection.Flat or ScenePriceDirection.Unknown => null,
+ ScenePriceDirection.Down => "down",
+ ScenePriceDirection.LimitDown => "downdown",
+ _ => throw new LegacySceneDataException("Unknown candle quote direction.")
+ };
+ if (active is not null)
+ {
+ mutations.Add(new PlayoutSetVisible(active, true));
+ }
+
+ var selected = direction switch
+ {
+ ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1,
+ ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2,
+ ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3,
+ _ => throw new LegacySceneDataException("Unknown candle quote direction.")
+ };
+ for (var index = 1; index <= 3; index++)
+ {
+ mutations.Add(new PlayoutSetVisible("bg" + index, index == selected));
+ }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs
new file mode 100644
index 0000000..23b7f21
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs
@@ -0,0 +1,1057 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Closed request contract for the production s8010 candle queries. InstrumentName is a
+/// bound value only; it is never interpolated into SQL or used as an identifier.
+///
+public sealed record S8010SceneDataRequest(
+ CandleCutAlias CutAlias,
+ CandleMarketTarget MarketTarget,
+ CandleChartMode ChartMode,
+ string? InstrumentName,
+ bool ShowMovingAverage5,
+ bool ShowMovingAverage20);
+
+///
+/// Loads the two legacy s8010 result sets (current quote, then ordered candles) through
+/// parameterized, read-only Oracle queries and maps them to the trusted scene DTO.
+///
+public sealed class S8010SceneDataLoader
+{
+ private const string CurrentResultName = "S8010_CURRENT";
+ private const string RowsResultName = "S8010_ROWS";
+ private readonly IDataQueryExecutor _executor;
+
+ public S8010SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S8010SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var selection = S8010CandleQueryFactory.ValidateRequest(request);
+ var plan = S8010CandleQueryFactory.Create(request, selection);
+
+ var current = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ CurrentResultName,
+ plan.Current,
+ cancellationToken).ConfigureAwait(false);
+ var quote = S8010CandleTableMapper.MapQuote(current);
+
+ var rows = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ RowsResultName,
+ plan.Rows,
+ cancellationToken).ConfigureAwait(false);
+ var candles = S8010CandleTableMapper.MapRows(
+ rows,
+ plan.IsIntraday,
+ plan.MaximumRows);
+ var data = new S8010SceneData(
+ request.CutAlias,
+ request.MarketTarget,
+ request.ChartMode,
+ quote.DisplayName,
+ quote.Quote,
+ candles,
+ request.ShowMovingAverage5,
+ request.ShowMovingAverage20);
+
+ // Use the scene builder as the final DTO invariant check. No COM operation occurs.
+ _ = new S8010SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+internal static class S8010CandleQueryFactory
+{
+ private const int MaximumSelectionLength = 256;
+ private const string NullNumber = "CAST(NULL AS NUMBER)";
+ private const string NullTime = "CAST(NULL AS VARCHAR2(6))";
+
+ public static string? ValidateRequest(S8010SceneDataRequest request)
+ {
+ if (!Enum.IsDefined(request.CutAlias) ||
+ !Enum.IsDefined(request.MarketTarget) ||
+ !Enum.IsDefined(request.ChartMode))
+ {
+ throw InvalidRequest();
+ }
+
+ var requiresSelection = RequiresSelection(request.MarketTarget);
+ if (!requiresSelection)
+ {
+ if (!string.IsNullOrWhiteSpace(request.InstrumentName))
+ {
+ throw InvalidRequest();
+ }
+
+ return null;
+ }
+
+ var selection = LegacySceneBuilderGuard.Text(
+ request.InstrumentName,
+ nameof(request.InstrumentName));
+ if (selection.Length > MaximumSelectionLength)
+ {
+ throw InvalidRequest();
+ }
+
+ return selection;
+ }
+
+ public static S8010CandleQueryPlan Create(
+ S8010SceneDataRequest request,
+ string? selection)
+ {
+ var isIntraday = request.CutAlias == CandleCutAlias.Cut8035;
+ var maximumRows = MaximumRows(request.CutAlias);
+ EnsureSupported(request, isIntraday);
+
+ var queries = request.MarketTarget switch
+ {
+ CandleMarketTarget.KospiIndex => DomesticIndex(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ new DomesticIndexSource(
+ "t_index",
+ "t_fo_index",
+ "t_index_his_5M",
+ "t_index_his_day",
+ "001")),
+ CandleMarketTarget.KosdaqIndex => DomesticIndex(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ new DomesticIndexSource(
+ "t_kosdaq_index",
+ "t_kosdaq_fo_index",
+ "t_kosdaq_index_his_5M",
+ "t_kosdaq_index_his_day",
+ "001")),
+ CandleMarketTarget.Kospi200Index => DomesticIndex(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ new DomesticIndexSource(
+ "T_200_INDEX",
+ "T_200_FO_INDEX",
+ "t_200_index_his_5M",
+ "t_200_index_his_day",
+ "029")),
+ CandleMarketTarget.Krx100Index => DomesticIndex(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ new DomesticIndexSource(
+ "t_krx100_index",
+ null,
+ "t_krx100_index_his_5M",
+ "t_krx100_index_his_day",
+ "043")),
+ CandleMarketTarget.FuturesIndex => Futures(
+ request.ChartMode,
+ isIntraday,
+ maximumRows),
+ CandleMarketTarget.KospiIndustry => Industry(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ selection!,
+ new IndustrySource(
+ "t_index",
+ "t_part",
+ "t_index_his_5M",
+ "t_index_his_day")),
+ CandleMarketTarget.KosdaqIndustry => Industry(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ selection!,
+ new IndustrySource(
+ "t_kosdaq_index",
+ "t_kosdaq_part",
+ "t_kosdaq_index_his_5M",
+ "t_kosdaq_index_his_day")),
+ CandleMarketTarget.OverseasIndex => Foreign(
+ maximumRows,
+ selection!,
+ "0"),
+ CandleMarketTarget.OverseasStock => Foreign(
+ maximumRows,
+ selection!,
+ "1"),
+ CandleMarketTarget.KospiStock => Stock(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ selection!,
+ new StockSource(
+ "t_online1",
+ "t_online1_call",
+ "t_stop_online1",
+ "t_online_his_5M",
+ "t_candle_history",
+ "t_stock",
+ "t_batch_day")),
+ CandleMarketTarget.KosdaqStock => Stock(
+ request.ChartMode,
+ isIntraday,
+ maximumRows,
+ selection!,
+ new StockSource(
+ "t_kosdaq_online1",
+ "t_kosdaq_online1_call",
+ "t_stop_kosdaq_online1",
+ "t_kosdaq_online_his_5M",
+ "t_kosdaq_candle_history",
+ "t_kosdaq_stock",
+ "t_kosdaq_batch_day")),
+ _ => throw InvalidRequest()
+ };
+
+ return new S8010CandleQueryPlan(
+ queries.Current,
+ queries.Rows,
+ maximumRows,
+ isIntraday);
+ }
+
+ private static QueryPair DomesticIndex(
+ CandleChartMode mode,
+ bool isIntraday,
+ int maximumRows,
+ DomesticIndexSource source)
+ {
+ var expected = mode == CandleChartMode.ExpectedExecution;
+ var currentTable = expected ? source.ForecastTable! : source.CurrentTable;
+ var changeExpression = expected
+ ? "ABS(ROUND(f_part_chg / 100, 2))"
+ : "ROUND(f_part_chg / 100, 2)";
+ var currentSql = $"""
+ SELECT
+ :displayName DISPLAY_NAME,
+ ROUND(f_part_idx / 100, 2) CURRENT_PRICE,
+ {changeExpression} CHANGE_PRICE,
+ ROUND((f_part_chg / DECODE(
+ f_chg_type,
+ '+', f_part_idx - f_part_chg,
+ '-', f_part_idx + f_part_chg,
+ 1)) * 100, 2) RATE,
+ f_chg_type DIRECTION_CODE
+ FROM {currentTable}
+ WHERE f_part_code = :partCode
+ """;
+ var current = Oracle(
+ currentSql,
+ new DataQueryParameter("displayName", "\uC9C0\uC218", DbType.String),
+ new DataQueryParameter("partCode", source.PartCode, DbType.String));
+ var volume = mode == CandleChartMode.PriceWithVolume
+ ? "a.F_PART_VOL"
+ : NullNumber;
+ var rowsSql = isIntraday
+ ? $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ a.F_DATA_TIME TRADING_TIME,
+ a.F_PART_INIT_IDX / 100 OPEN_PRICE,
+ a.F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ a.F_PART_LOW_IDX / 100 LOW_PRICE,
+ a.F_PART_IDX / 100 CLOSE_PRICE,
+ {volume} VOLUME,
+ {NullNumber} MA5,
+ {NullNumber} MA20
+ FROM {source.FiveMinuteTable} a
+ WHERE a.f_part_code = :partCode
+ AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day)
+ ORDER BY a.F_DATA_DAY, a.F_DATA_TIME
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE, TRADING_TIME
+ """
+ : $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ {NullTime} TRADING_TIME,
+ a.F_PART_INIT_IDX / 100 OPEN_PRICE,
+ a.F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ a.F_PART_LOW_IDX / 100 LOW_PRICE,
+ a.F_PART_IDX / 100 CLOSE_PRICE,
+ {volume} VOLUME,
+ a.F_PART_MA5_IDX / 100 MA5,
+ a.F_PART_MA20_IDX / 100 MA20
+ FROM {source.DailyTable} a
+ WHERE a.f_part_code = :partCode
+ ORDER BY a.F_DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE
+ """;
+ var rows = Oracle(
+ rowsSql,
+ new DataQueryParameter("partCode", source.PartCode, DbType.String),
+ new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
+ return new QueryPair(current, rows);
+ }
+
+ private static QueryPair Futures(
+ CandleChartMode mode,
+ bool isIntraday,
+ int maximumRows)
+ {
+ var currentSql = mode == CandleChartMode.ExpectedExecution
+ ? """
+ SELECT
+ :displayName DISPLAY_NAME,
+ part_idx CURRENT_PRICE,
+ ABS(part_chg) CHANGE_PRICE,
+ ROUND(part_chg / DECODE(
+ chg_type,
+ '+', (part_idx / 100) - (part_chg / 100),
+ '-', (part_idx / 100) + (part_chg / 100),
+ 1), 2) RATE,
+ chg_type DIRECTION_CODE
+ FROM (
+ SELECT
+ DECODE(a.f_fo_price, 0, b.F_JUN_LAST_PRICE, a.f_fo_price) part_idx,
+ DECODE(a.f_fo_price, 0, 0,
+ (a.f_fo_price - b.F_JUN_LAST_PRICE) / 100) part_chg,
+ DECODE(a.f_fo_price, 0, ' ',
+ DECODE(SIGN(a.f_fo_price - b.F_JUN_LAST_PRICE),
+ 1, '+', -1, '-', 0, ' ')) chg_type
+ FROM t_sunmul_online a, t_sunmul_batch b
+ WHERE a.f_stock_code = b.f_stock_code
+ AND a.f_stock_seq = 1
+ AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day)
+ AND b.F_MONTH_GUBUN = '1'
+ )
+ """
+ : """
+ SELECT
+ :displayName DISPLAY_NAME,
+ part_idx CURRENT_PRICE,
+ ABS(part_chg) CHANGE_PRICE,
+ ABS(ROUND(part_chg / DECODE(
+ chg_type,
+ '+', (part_idx / 100) - (part_chg / 100),
+ '-', (part_idx / 100) + (part_chg / 100),
+ 1), 2)) RATE,
+ chg_type DIRECTION_CODE
+ FROM (
+ SELECT
+ DECODE(a.f_curr_price, 0,
+ b.F_JUN_LAST_PRICE / 100,
+ a.f_curr_price / 100) part_idx,
+ DECODE(a.f_curr_price, 0, 0,
+ (a.f_curr_price - b.F_JUN_LAST_PRICE * 100) / 100) part_chg,
+ DECODE(a.f_curr_price, 0, ' ',
+ DECODE(SIGN(a.f_curr_price - b.F_JUN_LAST_PRICE * 100),
+ 1, '+', -1, '-', 0, ' ')) chg_type
+ FROM t_sunmul_online a, t_sunmul_batch b
+ WHERE a.f_stock_code = b.f_stock_code
+ AND a.f_stock_seq = 1
+ AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day)
+ AND b.F_MONTH_GUBUN = '1'
+ )
+ """;
+ var current = Oracle(
+ currentSql,
+ new DataQueryParameter("displayName", "\uC9C0\uC218", DbType.String));
+ var rowsSql = isIntraday
+ ? """
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ a.F_DATA_TIME TRADING_TIME,
+ a.F_PART_INIT_IDX / 100 OPEN_PRICE,
+ a.F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ a.F_PART_LOW_IDX / 100 LOW_PRICE,
+ a.F_PART_IDX / 100 CLOSE_PRICE,
+ CAST(NULL AS NUMBER) VOLUME,
+ CAST(NULL AS NUMBER) MA5,
+ CAST(NULL AS NUMBER) MA20
+ FROM t_sunmul_his_5M a
+ WHERE a.f_data_day = (SELECT MAX(open_day) FROM v_open_day)
+ ORDER BY a.F_DATA_DAY, a.F_DATA_TIME
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE, TRADING_TIME
+ """
+ : """
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ F_DATA_DAY TRADING_DATE,
+ CAST(NULL AS VARCHAR2(6)) TRADING_TIME,
+ F_PART_INIT_IDX / 100 OPEN_PRICE,
+ F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ F_PART_LOW_IDX / 100 LOW_PRICE,
+ F_PART_IDX / 100 CLOSE_PRICE,
+ CAST(NULL AS NUMBER) VOLUME,
+ F_PART_MA5_IDX / 100 MA5,
+ F_PART_MA20_IDX / 100 MA20
+ FROM T_SUNMUL_HIS_DAY
+ ORDER BY F_DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE
+ """;
+ var rows = Oracle(
+ rowsSql,
+ new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
+ return new QueryPair(current, rows);
+ }
+
+ private static QueryPair Industry(
+ CandleChartMode mode,
+ bool isIntraday,
+ int maximumRows,
+ string selection,
+ IndustrySource source)
+ {
+ var currentSql = $"""
+ SELECT
+ b.f_part_name DISPLAY_NAME,
+ ROUND(a.f_part_idx / 100, 2) CURRENT_PRICE,
+ ROUND(a.f_part_chg / 100, 2) CHANGE_PRICE,
+ ROUND((a.f_part_chg / DECODE(
+ a.f_chg_type,
+ '+', a.f_part_idx - a.f_part_chg,
+ '-', a.f_part_idx + a.f_part_chg,
+ 1)) * 100, 2) RATE,
+ a.f_chg_type DIRECTION_CODE
+ FROM {source.CurrentTable} a, {source.MasterTable} b
+ WHERE a.f_part_code = b.f_part_code
+ AND b.f_part_name = :selection
+ """;
+ var current = Oracle(
+ currentSql,
+ new DataQueryParameter("selection", selection, DbType.String));
+ var volume = mode == CandleChartMode.PriceWithVolume
+ ? "a.F_PART_VOL"
+ : NullNumber;
+ var rowsSql = isIntraday
+ ? $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ a.F_DATA_TIME TRADING_TIME,
+ a.F_PART_INIT_IDX / 100 OPEN_PRICE,
+ a.F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ a.F_PART_LOW_IDX / 100 LOW_PRICE,
+ a.F_PART_IDX / 100 CLOSE_PRICE,
+ {volume} VOLUME,
+ {NullNumber} MA5,
+ {NullNumber} MA20
+ FROM {source.FiveMinuteTable} a, {source.MasterTable} b
+ WHERE a.f_part_code = b.f_part_code
+ AND b.f_part_name = :selection
+ AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day)
+ ORDER BY a.F_DATA_DAY, a.F_DATA_TIME
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE, TRADING_TIME
+ """
+ : $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ {NullTime} TRADING_TIME,
+ a.F_PART_INIT_IDX / 100 OPEN_PRICE,
+ a.F_PART_HIGH_IDX / 100 HIGH_PRICE,
+ a.F_PART_LOW_IDX / 100 LOW_PRICE,
+ a.F_PART_IDX / 100 CLOSE_PRICE,
+ {volume} VOLUME,
+ a.F_PART_MA5_IDX / 100 MA5,
+ a.F_PART_MA20_IDX / 100 MA20
+ FROM {source.DailyTable} a, {source.MasterTable} b
+ WHERE a.f_part_code = b.f_part_code
+ AND b.f_part_name = :selection
+ ORDER BY a.F_DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE
+ """;
+ var rows = Oracle(
+ rowsSql,
+ new DataQueryParameter("selection", selection, DbType.String),
+ new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
+ return new QueryPair(current, rows);
+ }
+
+ private static QueryPair Foreign(
+ int maximumRows,
+ string selection,
+ string instrumentType)
+ {
+ const string currentSql = """
+ SELECT DISTINCT
+ a.f_input_name DISPLAY_NAME,
+ ROUND(b.f_last, 2) CURRENT_PRICE,
+ ABS(ROUND(b.f_diff, 2)) CHANGE_PRICE,
+ ROUND(b.f_rate, 2) RATE,
+ b.f_sign DIRECTION_CODE
+ FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
+ WHERE a.f_symb = b.f_symb
+ AND a.f_input_name = :selection
+ AND b.f_fdtc = :instrumentType
+ """;
+ var current = Oracle(
+ currentSql,
+ new DataQueryParameter("selection", selection, DbType.String),
+ new DataQueryParameter("instrumentType", instrumentType, DbType.String));
+ const string rowsSql = """
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT DISTINCT
+ b.F_xYMD TRADING_DATE,
+ CAST(NULL AS VARCHAR2(6)) TRADING_TIME,
+ b.f_open OPEN_PRICE,
+ b.f_high HIGH_PRICE,
+ b.f_low LOW_PRICE,
+ b.f_last CLOSE_PRICE,
+ CAST(NULL AS NUMBER) VOLUME,
+ CAST(NULL AS NUMBER) MA5,
+ CAST(NULL AS NUMBER) MA20
+ FROM t_world_ix_eq_master a, t_world_ix_eq_his b
+ WHERE a.f_symb = b.f_symb
+ AND a.f_input_name = :selection
+ AND b.f_fdtc = :instrumentType
+ ORDER BY b.F_xYMD DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE
+ """;
+ var rows = Oracle(
+ rowsSql,
+ new DataQueryParameter("selection", selection, DbType.String),
+ new DataQueryParameter("instrumentType", instrumentType, DbType.String),
+ new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
+ return new QueryPair(current, rows);
+ }
+
+ private static QueryPair Stock(
+ CandleChartMode mode,
+ bool isIntraday,
+ int maximumRows,
+ string selection,
+ StockSource source)
+ {
+ var haltCode = mode == CandleChartMode.TradingHalt ? "Y" : "N";
+ string currentSql;
+ if (mode == CandleChartMode.ExpectedExecution)
+ {
+ currentSql = $"""
+ SELECT DISTINCT
+ b.F_STOCK_WANNAME DISPLAY_NAME,
+ a.f_fore_price CURRENT_PRICE,
+ ABS(a.f_fore_price - c.f_final_price) CHANGE_PRICE,
+ ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE,
+ CASE
+ WHEN a.f_fore_price - c.f_final_price > 0 THEN '+'
+ WHEN a.f_fore_price - c.f_final_price = 0 THEN ' '
+ WHEN a.f_fore_price - c.f_final_price < 0 THEN '-'
+ END DIRECTION_CODE
+ FROM {source.ForecastTable} a, {source.MasterTable} b, {source.BatchTable} c
+ WHERE b.f_stock_wanname = :selection
+ AND b.f_mkt_halt = :haltCode
+ AND a.f_stock_code = b.f_stock_code
+ AND c.f_stock_code = b.f_stock_code
+ """;
+ }
+ else
+ {
+ var currentTable = mode == CandleChartMode.TradingHalt
+ ? source.HaltedCurrentTable
+ : source.CurrentTable;
+ currentSql = $"""
+ SELECT DISTINCT
+ b.F_STOCK_WANNAME DISPLAY_NAME,
+ a.f_curr_price CURRENT_PRICE,
+ ABS(a.f_net_chg) CHANGE_PRICE,
+ ROUND((a.f_net_chg / DECODE(
+ a.f_chg_type,
+ '1', a.f_curr_price - a.f_net_chg,
+ '2', a.f_curr_price - a.f_net_chg,
+ '3', a.f_curr_price,
+ '4', -1 * (a.f_curr_price + a.f_net_chg),
+ '5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE,
+ a.f_chg_type DIRECTION_CODE
+ FROM {currentTable} a, {source.MasterTable} b
+ WHERE b.f_stock_wanname = :selection
+ AND b.f_mkt_halt = :haltCode
+ AND a.f_curr_price <> 0
+ AND a.f_stock_code = b.f_stock_code
+ """;
+ }
+
+ var current = Oracle(
+ currentSql,
+ new DataQueryParameter("selection", selection, DbType.String),
+ new DataQueryParameter("haltCode", haltCode, DbType.String));
+ var volume = mode == CandleChartMode.PriceWithVolume
+ ? "a.F_NET_VOL"
+ : NullNumber;
+ var rowsSql = isIntraday
+ ? $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DAY TRADING_DATE,
+ a.F_DATA_TIME TRADING_TIME,
+ a.F_INIT_PRICE OPEN_PRICE,
+ a.F_HIGH_PRICE HIGH_PRICE,
+ a.F_LOW_PRICE LOW_PRICE,
+ a.F_CURR_PRICE CLOSE_PRICE,
+ {volume} VOLUME,
+ {NullNumber} MA5,
+ {NullNumber} MA20
+ FROM {source.FiveMinuteTable} a, {source.MasterTable} b
+ WHERE b.f_stock_wanname = :selection
+ AND b.f_mkt_halt = :haltCode
+ AND a.f_stock_code = b.f_stock_code
+ AND a.f_curr_price <> 0
+ AND a.f_data_day = (SELECT MAX(open_day) FROM v_open_day)
+ ORDER BY a.F_DATA_DAY, a.F_DATA_TIME
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE, TRADING_TIME
+ """
+ : $"""
+ SELECT
+ TRADING_DATE, TRADING_TIME,
+ OPEN_PRICE, HIGH_PRICE, LOW_PRICE, CLOSE_PRICE,
+ VOLUME, MA5, MA20
+ FROM (
+ SELECT
+ a.F_DATA_DATE TRADING_DATE,
+ {NullTime} TRADING_TIME,
+ a.F_INIT_PRICE OPEN_PRICE,
+ a.F_HIGH_PRICE HIGH_PRICE,
+ a.F_LOW_PRICE LOW_PRICE,
+ a.F_CURR_PRICE CLOSE_PRICE,
+ {volume} VOLUME,
+ a.F_MA5_PRICE MA5,
+ a.F_MA20_PRICE MA20
+ FROM {source.DailyTable} a, {source.MasterTable} b
+ WHERE b.f_stock_wanname = :selection
+ AND b.f_mkt_halt = :haltCode
+ AND a.f_stock_code = b.f_stock_code
+ ORDER BY a.F_DATA_DATE DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ORDER BY TRADING_DATE
+ """;
+ var rows = Oracle(
+ rowsSql,
+ new DataQueryParameter("selection", selection, DbType.String),
+ new DataQueryParameter("haltCode", haltCode, DbType.String),
+ new DataQueryParameter("rowLimit", maximumRows, DbType.Int32));
+ return new QueryPair(current, rows);
+ }
+
+ private static void EnsureSupported(S8010SceneDataRequest request, bool isIntraday)
+ {
+ var supported = request.MarketTarget switch
+ {
+ CandleMarketTarget.KospiIndex or
+ CandleMarketTarget.KosdaqIndex or
+ CandleMarketTarget.Kospi200Index => request.ChartMode switch
+ {
+ CandleChartMode.Price or CandleChartMode.PriceWithVolume => true,
+ CandleChartMode.ExpectedExecution => !isIntraday,
+ _ => false
+ },
+ CandleMarketTarget.Krx100Index =>
+ request.ChartMode is CandleChartMode.Price or CandleChartMode.PriceWithVolume,
+ CandleMarketTarget.FuturesIndex =>
+ request.ChartMode == CandleChartMode.Price ||
+ (request.ChartMode == CandleChartMode.ExpectedExecution && !isIntraday),
+ CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry =>
+ request.ChartMode is CandleChartMode.Price or CandleChartMode.PriceWithVolume,
+ CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock =>
+ request.ChartMode == CandleChartMode.Price && !isIntraday,
+ CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock =>
+ request.ChartMode switch
+ {
+ CandleChartMode.Price or CandleChartMode.PriceWithVolume => true,
+ CandleChartMode.ExpectedExecution or CandleChartMode.TradingHalt => !isIntraday,
+ _ => false
+ },
+ _ => false
+ };
+
+ if (!supported)
+ {
+ throw InvalidRequest();
+ }
+ }
+
+ private static bool RequiresSelection(CandleMarketTarget target) => target is
+ CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry or
+ CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock or
+ CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock;
+
+ private static int MaximumRows(CandleCutAlias alias) => alias switch
+ {
+ CandleCutAlias.Cut8035 => 78,
+ CandleCutAlias.Cut8061 => 5,
+ CandleCutAlias.Cut8040 => 20,
+ CandleCutAlias.Cut8046 => 60,
+ CandleCutAlias.Cut8051 => 120,
+ CandleCutAlias.Cut8056 => 240,
+ _ => throw InvalidRequest()
+ };
+
+ private static DataQuerySpec Oracle(
+ string sql,
+ params DataQueryParameter[] parameters)
+ {
+ var spec = new DataQuerySpec(sql, parameters);
+ spec.ValidateFor(DataSourceKind.Oracle);
+ return spec;
+ }
+
+ private static LegacySceneDataException InvalidRequest() =>
+ new("The candle scene data request is unsupported or invalid.");
+
+ private sealed record DomesticIndexSource(
+ string CurrentTable,
+ string? ForecastTable,
+ string FiveMinuteTable,
+ string DailyTable,
+ string PartCode);
+
+ private sealed record IndustrySource(
+ string CurrentTable,
+ string MasterTable,
+ string FiveMinuteTable,
+ string DailyTable);
+
+ private sealed record StockSource(
+ string CurrentTable,
+ string ForecastTable,
+ string HaltedCurrentTable,
+ string FiveMinuteTable,
+ string DailyTable,
+ string MasterTable,
+ string BatchTable);
+
+ private sealed record QueryPair(DataQuerySpec Current, DataQuerySpec Rows);
+}
+
+internal sealed record S8010CandleQueryPlan(
+ DataQuerySpec Current,
+ DataQuerySpec Rows,
+ int MaximumRows,
+ bool IsIntraday);
+
+internal static class S8010CandleTableMapper
+{
+ private static readonly string[] QuoteColumns =
+ [
+ "DISPLAY_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"
+ ];
+
+ private static readonly string[] RowColumns =
+ [
+ "TRADING_DATE", "TRADING_TIME",
+ "OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE", "CLOSE_PRICE",
+ "VOLUME", "MA5", "MA20"
+ ];
+
+ public static MappedQuote MapQuote(DataTable? table)
+ {
+ RequireSchema(table, QuoteColumns);
+ if (table!.Rows.Count != 1)
+ {
+ throw InvalidResult();
+ }
+
+ var row = table.Rows[0];
+ var displayName = RequiredText(row, "DISPLAY_NAME");
+ var quote = new CandleQuoteData(
+ RequiredDouble(row, "CURRENT_PRICE"),
+ RequiredDouble(row, "CHANGE_PRICE"),
+ RequiredDouble(row, "RATE"),
+ Direction(row["DIRECTION_CODE"]));
+ return new MappedQuote(displayName, quote);
+ }
+
+ public static IReadOnlyList MapRows(
+ DataTable? table,
+ bool isIntraday,
+ int maximumRows)
+ {
+ RequireSchema(table, RowColumns);
+ if (table!.Rows.Count is < 1 || table.Rows.Count > maximumRows)
+ {
+ throw InvalidResult();
+ }
+
+ var result = new CandleRowData[table.Rows.Count];
+ DateOnly? previousDate = null;
+ TimeOnly? previousTime = null;
+ for (var index = 0; index < table.Rows.Count; index++)
+ {
+ var row = table.Rows[index];
+ var date = RequiredDate(row, "TRADING_DATE");
+ var time = OptionalTime(row, "TRADING_TIME");
+ if (isIntraday != time.HasValue)
+ {
+ throw InvalidResult();
+ }
+
+ if (previousDate.HasValue)
+ {
+ var compare = date.CompareTo(previousDate.Value);
+ if (compare < 0 ||
+ (compare == 0 && (!isIntraday ||
+ time!.Value.CompareTo(previousTime!.Value) <= 0)))
+ {
+ throw InvalidResult();
+ }
+ }
+
+ result[index] = new CandleRowData(
+ date,
+ time,
+ RequiredDouble(row, "OPEN_PRICE"),
+ RequiredDouble(row, "HIGH_PRICE"),
+ RequiredDouble(row, "LOW_PRICE"),
+ RequiredDouble(row, "CLOSE_PRICE"),
+ OptionalDouble(row, "VOLUME"),
+ OptionalDouble(row, "MA5"),
+ OptionalDouble(row, "MA20"));
+ previousDate = date;
+ previousTime = time;
+ }
+
+ return result;
+ }
+
+ private static void RequireSchema(DataTable? table, IReadOnlyList expected)
+ {
+ if (table is null || table.Columns.Count != expected.Count)
+ {
+ throw InvalidResult();
+ }
+
+ for (var index = 0; index < expected.Count; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ expected[index],
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw InvalidResult();
+ }
+ }
+ }
+
+ private static string RequiredText(DataRow row, string column)
+ {
+ var value = row[column];
+ if (value is null or DBNull)
+ {
+ throw InvalidResult();
+ }
+
+ var text = Convert.ToString(value, CultureInfo.InvariantCulture);
+ try
+ {
+ return LegacySceneBuilderGuard.Text(text, column);
+ }
+ catch (LegacySceneDataException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ private static double RequiredDouble(DataRow row, string column)
+ {
+ var value = OptionalDouble(row, column);
+ return value ?? throw InvalidResult();
+ }
+
+ private static double? OptionalDouble(DataRow row, string column)
+ {
+ var value = row[column];
+ if (value is null or DBNull)
+ {
+ return null;
+ }
+
+ try
+ {
+ var number = value is string text
+ ? double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)
+ : Convert.ToDouble(value, CultureInfo.InvariantCulture);
+ if (!double.IsFinite(number))
+ {
+ throw InvalidResult();
+ }
+
+ return number;
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ private static DateOnly RequiredDate(DataRow row, string column)
+ {
+ var value = row[column];
+ if (value is DateOnly dateOnly)
+ {
+ return dateOnly;
+ }
+
+ if (value is DateTime dateTime)
+ {
+ return DateOnly.FromDateTime(dateTime);
+ }
+
+ if (value is DateTimeOffset offset)
+ {
+ return DateOnly.FromDateTime(offset.DateTime);
+ }
+
+ if (value is null or DBNull)
+ {
+ throw InvalidResult();
+ }
+
+ var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
+ string[] formats = ["yyyyMMdd", "yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd"];
+ if (text is null || !DateOnly.TryParseExact(
+ text,
+ formats,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var result))
+ {
+ throw InvalidResult();
+ }
+
+ return result;
+ }
+
+ private static TimeOnly? OptionalTime(DataRow row, string column)
+ {
+ var value = row[column];
+ if (value is null or DBNull)
+ {
+ return null;
+ }
+
+ if (value is TimeOnly timeOnly)
+ {
+ return timeOnly;
+ }
+
+ if (value is DateTime dateTime)
+ {
+ return TimeOnly.FromDateTime(dateTime);
+ }
+
+ if (value is TimeSpan span && span >= TimeSpan.Zero && span < TimeSpan.FromDays(1))
+ {
+ return TimeOnly.FromTimeSpan(span);
+ }
+
+ var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ return null;
+ }
+
+ if (text.All(char.IsAsciiDigit))
+ {
+ text = text.Length switch
+ {
+ <= 4 => text.PadLeft(4, '0'),
+ <= 6 => text.PadLeft(6, '0'),
+ _ => text
+ };
+ }
+
+ string[] formats = ["HHmm", "HHmmss", "HH:mm", "HH:mm:ss"];
+ if (!TimeOnly.TryParseExact(
+ text,
+ formats,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var result))
+ {
+ throw InvalidResult();
+ }
+
+ return result;
+ }
+
+ private static ScenePriceDirection Direction(object? value)
+ {
+ var text = value is null or DBNull
+ ? string.Empty
+ : Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
+ return text switch
+ {
+ "\uC0C1\uD55C" or "1" => ScenePriceDirection.LimitUp,
+ "\uC0C1\uC2B9" or "+" or "2" => ScenePriceDirection.Up,
+ null or "" or "\uBCF4\uD569" or "3" => ScenePriceDirection.Flat,
+ "\uD558\uB77D" or "-" or "5" => ScenePriceDirection.Down,
+ "\uD558\uD55C" or "4" => ScenePriceDirection.LimitDown,
+ _ => throw InvalidResult()
+ };
+ }
+
+ private static LegacySceneDataException InvalidResult() =>
+ new("The candle scene query returned invalid data or an unexpected schema.");
+
+ internal sealed record MappedQuote(string DisplayName, CandleQuoteData Quote);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs
new file mode 100644
index 0000000..35ff4a9
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartLegacySceneRequestResolver.cs
@@ -0,0 +1,274 @@
+#nullable enable
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Closed result for the five chart cuts whose legacy constructors selected a DB
+/// branch from MainForm's code/jongmok/forCutInfo/sub playlist columns.
+///
+public abstract record LegacyChartSceneLoadRequest(string BuilderKey);
+
+public sealed record LegacyS5078ChartLoadRequest(S5078SceneDataRequest Request)
+ : LegacyChartSceneLoadRequest("s5078");
+
+public sealed record LegacyS5079ChartLoadRequest(S5079SceneDataRequest Request)
+ : LegacyChartSceneLoadRequest("s5079");
+
+public sealed record LegacyS5080ChartLoadRequest(S5080SceneDataRequest Request)
+ : LegacyChartSceneLoadRequest("s5080");
+
+public sealed record LegacyS5083ChartLoadRequest(S5083SceneDataRequest Request)
+ : LegacyChartSceneLoadRequest("s5083");
+
+public sealed record LegacyS5084ChartLoadRequest(S5084SceneDataRequest Request)
+ : LegacyChartSceneLoadRequest("s5084");
+
+///
+/// Reproduces MainForm's five chart-constructor inputs without carrying forward its
+/// substring-based cut dispatch. LegacySceneSelection maps GroupCode=code,
+/// Subject=jongmok, GraphicType=forCutInfo, Subtype=sub and DataCode=the hidden
+/// stock-code column. Every accepted selector belongs to an explicit closed set.
+///
+public sealed class ChartLegacySceneRequestResolver
+{
+ private const int MaximumFieldLength = 256;
+ private const int MaximumDataCodeLength = 32;
+
+ public LegacyChartSceneLoadRequest Resolve(LegacyPlaylistEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ return entry.CutCode switch
+ {
+ "5078" => new LegacyS5078ChartLoadRequest(CreateS5078Request(entry)),
+ "5079" => new LegacyS5079ChartLoadRequest(CreateS5079Request(entry)),
+ "5080" => new LegacyS5080ChartLoadRequest(CreateS5080Request(entry)),
+ "5083" => new LegacyS5083ChartLoadRequest(CreateS5083Request(entry)),
+ "5084" => new LegacyS5084ChartLoadRequest(CreateS5084Request(entry)),
+ _ => throw InvalidSelection()
+ };
+ }
+
+ public S5078SceneDataRequest CreateS5078Request(LegacyPlaylistEntry entry)
+ {
+ var selection = Require(entry, "5078");
+ RequireEmpty(selection.Subtype);
+ RequireEmpty(selection.DataCode);
+
+ var subject = RequiredExact(selection.Subject);
+ var group = OptionalTrimmed(selection.GroupCode);
+ var graphic = RequiredTrimmed(selection.GraphicType);
+ var market = subject switch
+ {
+ "다우" or "DOW" => SectorIndexMarket.Dow,
+ "나스닥" or "NASDAQ" => SectorIndexMarket.Nasdaq,
+ "S&P" or "S&P500" => SectorIndexMarket.StandardAndPoor,
+ "코스피" or "KOSPI" => SectorIndexMarket.Kospi,
+ "코스닥" or "KOSDAQ" => SectorIndexMarket.Kosdaq,
+ _ => throw InvalidSelection()
+ };
+
+ if (market is SectorIndexMarket.Dow or
+ SectorIndexMarket.Nasdaq or
+ SectorIndexMarket.StandardAndPoor)
+ {
+ if (!IsOneOf(group, "해외지수", "FOREIGN_INDEX") ||
+ !IsOneOf(graphic, "미국업종", "US_SECTOR_INDEX"))
+ {
+ throw InvalidSelection();
+ }
+ }
+ else if (market == SectorIndexMarket.Kospi)
+ {
+ if (!IsOneOf(group, "업종_코스피", "INDUSTRY_KOSPI") ||
+ !IsOneOf(graphic, "섹터지수", "SECTOR_INDEX"))
+ {
+ throw InvalidSelection();
+ }
+ }
+ else if (!IsOneOf(group, "업종_코스닥", "INDUSTRY_KOSDAQ") ||
+ !IsOneOf(graphic, "섹터지수", "SECTOR_INDEX"))
+ {
+ throw InvalidSelection();
+ }
+
+ return new S5078SceneDataRequest(market);
+ }
+
+ public S5079SceneDataRequest CreateS5079Request(LegacyPlaylistEntry entry)
+ {
+ var selection = Require(entry, "5079");
+ ValidateManualStockSelection(selection, "성장성 지표", "GROWTH_METRICS");
+ return new S5079SceneDataRequest(RequiredExact(selection.Subject));
+ }
+
+ public S5080SceneDataRequest CreateS5080Request(LegacyPlaylistEntry entry)
+ {
+ var selection = Require(entry, "5080");
+ ValidateManualStockSelection(selection, "매출액", "SALES");
+ return new S5080SceneDataRequest(RequiredExact(selection.Subject));
+ }
+
+ public S5083SceneDataRequest CreateS5083Request(LegacyPlaylistEntry entry)
+ {
+ var selection = Require(entry, "5083");
+ RequireIndexLineGraph(selection, expectedGroup: "전체", canonicalGroup: "ALL");
+ var participant = RequiredTrimmed(selection.GraphicType) switch
+ {
+ "개인 매매동향" or "INDIVIDUAL_TRADING_TREND" =>
+ InvestorFlowParticipant.Individual,
+ "기관 매매동향" or "INSTITUTION_TRADING_TREND" =>
+ InvestorFlowParticipant.Institution,
+ "외국인 매매동향" or "FOREIGN_TRADING_TREND" =>
+ InvestorFlowParticipant.Foreign,
+ _ => throw InvalidSelection()
+ };
+ return new S5083SceneDataRequest(participant);
+ }
+
+ public S5084SceneDataRequest CreateS5084Request(LegacyPlaylistEntry entry)
+ {
+ var selection = Require(entry, "5084");
+ RequireCommonIndexLineFields(selection);
+ var group = RequiredTrimmed(selection.GroupCode);
+ var index = RequiredTrimmed(selection.GraphicType) switch
+ {
+ "코스피 매매동향" or "KOSPI_TRADING_TREND"
+ when IsOneOf(group, "코스피", "KOSPI") => InvestorFlowIndex.Kospi,
+ "코스닥 매매동향" or "KOSDAQ_TRADING_TREND"
+ when IsOneOf(group, "코스닥", "KOSDAQ") => InvestorFlowIndex.Kosdaq,
+ _ => throw InvalidSelection()
+ };
+ return new S5084SceneDataRequest(index);
+ }
+
+ private static void ValidateManualStockSelection(
+ LegacySceneSelection selection,
+ string originalGraphic,
+ string canonicalGraphic)
+ {
+ _ = RequiredExact(selection.Subject);
+ var group = RequiredTrimmed(selection.GroupCode);
+ if (!IsOneOf(
+ group,
+ "코스피",
+ "코스닥",
+ "코스피_NXT",
+ "코스닥_NXT",
+ "KOSPI",
+ "KOSDAQ",
+ "NXT_KOSPI",
+ "NXT_KOSDAQ"))
+ {
+ throw InvalidSelection();
+ }
+
+ if (!IsOneOf(RequiredTrimmed(selection.GraphicType), originalGraphic, canonicalGraphic))
+ {
+ throw InvalidSelection();
+ }
+
+ RequireEmpty(selection.Subtype);
+ ValidateOptionalDataCode(selection.DataCode);
+ }
+
+ private static void RequireIndexLineGraph(
+ LegacySceneSelection selection,
+ string expectedGroup,
+ string canonicalGroup)
+ {
+ RequireCommonIndexLineFields(selection);
+ if (!IsOneOf(RequiredTrimmed(selection.GroupCode), expectedGroup, canonicalGroup))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireCommonIndexLineFields(LegacySceneSelection selection)
+ {
+ if (!IsOneOf(RequiredTrimmed(selection.Subject), "지수", "INDEX") ||
+ !IsOneOf(RequiredTrimmed(selection.Subtype), "라인그래프", "LINE_GRAPH"))
+ {
+ throw InvalidSelection();
+ }
+
+ RequireEmpty(selection.DataCode);
+ }
+
+ private static LegacySceneSelection Require(LegacyPlaylistEntry entry, string cutCode)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.CutCode != cutCode || entry.Selection is null)
+ {
+ throw InvalidSelection();
+ }
+
+ ValidateField(entry.Selection.GroupCode);
+ ValidateField(entry.Selection.Subject);
+ ValidateField(entry.Selection.GraphicType);
+ ValidateField(entry.Selection.Subtype);
+ ValidateField(entry.Selection.DataCode);
+ return entry.Selection;
+ }
+
+ private static string RequiredExact(string? value)
+ {
+ ValidateField(value);
+ if (string.IsNullOrWhiteSpace(value) || value != value.Trim())
+ {
+ throw InvalidSelection();
+ }
+
+ return value;
+ }
+
+ private static string RequiredTrimmed(string? value)
+ {
+ ValidateField(value);
+ var trimmed = value!.Trim();
+ return trimmed.Length != 0 ? trimmed : throw InvalidSelection();
+ }
+
+ private static string OptionalTrimmed(string? value)
+ {
+ ValidateField(value);
+ return value!.Trim();
+ }
+
+ private static void RequireEmpty(string? value)
+ {
+ ValidateField(value);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void ValidateOptionalDataCode(string? value)
+ {
+ ValidateField(value);
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ if (value.Length > MaximumDataCodeLength ||
+ value.Any(character => !char.IsAsciiLetterOrDigit(character)))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void ValidateField(string? value)
+ {
+ if (value is null || value.Length > MaximumFieldLength || value.Any(char.IsControl))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static bool IsOneOf(string value, params string[] accepted) =>
+ accepted.Contains(value, StringComparer.Ordinal);
+
+ private static LegacySceneDataException InvalidSelection() =>
+ new("The chart legacy selection is unsupported or invalid.");
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs
new file mode 100644
index 0000000..04085a1
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs
@@ -0,0 +1,735 @@
+#nullable enable
+
+using System.Globalization;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public enum SectorIndexMarket
+{
+ Dow,
+ Nasdaq,
+ StandardAndPoor,
+ Kospi,
+ Kosdaq
+}
+
+public enum SectorBarVariant
+{
+ Primary,
+ Secondary
+}
+
+public sealed record SectorIndexBarData(
+ string Category,
+ double Value,
+ SectorBarVariant Variant);
+
+public sealed record S5078SceneData(
+ SectorIndexMarket Market,
+ IReadOnlyList Rows) : ILegacySceneData;
+
+public sealed class S5078SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5078";
+
+ public IReadOnlyList Build(S5078SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var rows = LegacySceneBuilderGuard.Count(data.Rows, 10, nameof(data.Rows));
+ var (title, primaryScaleFactor) = data.Market switch
+ {
+ SectorIndexMarket.Dow => ("다우 섹터지수", -1d),
+ SectorIndexMarket.Nasdaq => ("나스닥 섹터지수", -1d),
+ SectorIndexMarket.StandardAndPoor => ("S&P 섹터지수", -1d),
+ SectorIndexMarket.Kospi => ("코스피 섹터지수", 1d),
+ SectorIndexMarket.Kosdaq => ("코스닥 섹터지수", 1d),
+ _ => throw new LegacySceneDataException("Unknown sector index market.")
+ };
+
+ var validatedRows = new SectorIndexBarData[rows.Count];
+ var maximumMagnitude = 0d;
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var row = LegacySceneBuilderGuard.NotNull(rows[index], $"Rows[{index}]");
+ LegacySceneBuilderGuard.Text(row.Category, $"Rows[{index}].Category");
+ if (!double.IsFinite(row.Value))
+ {
+ throw new LegacySceneDataException("Sector index values must be finite.");
+ }
+
+ _ = VariantSuffix(row.Variant);
+ validatedRows[index] = row;
+ maximumMagnitude = Math.Max(maximumMagnitude, Math.Abs(row.Value));
+ }
+
+ var mutations = new List(91)
+ {
+ new PlayoutSetValue("title", title)
+ };
+
+ for (var index = 1; index <= 10; index++)
+ {
+ mutations.Add(new PlayoutSetVisible($"bar{index}_1", false));
+ mutations.Add(new PlayoutSetVisible($"value{index}_1", false));
+ mutations.Add(new PlayoutSetVisible($"bar{index}_2", false));
+ mutations.Add(new PlayoutSetVisible($"value{index}_2", false));
+ }
+
+ for (var index = 1; index <= validatedRows.Length; index++)
+ {
+ var row = validatedRows[index - 1];
+ var suffix = VariantSuffix(row.Variant);
+ var scaleFactor = row.Variant == SectorBarVariant.Primary
+ ? primaryScaleFactor
+ : 1d;
+ var scale = maximumMagnitude == 0d
+ ? 0f
+ : LegacyChartSafety.FiniteFloat(
+ row.Value / maximumMagnitude * scaleFactor,
+ "Sector index scale");
+
+ mutations.Add(new PlayoutSetValue(
+ "category" + index,
+ row.Category));
+ mutations.Add(new PlayoutSetVisible($"bar{index}_{suffix}", true));
+ mutations.Add(new PlayoutSetVisible($"value{index}_{suffix}", true));
+ mutations.Add(new PlayoutSetScale(
+ $"bar{index}_{suffix}",
+ scale,
+ 0,
+ 0,
+ PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ $"value{index}_{suffix}",
+ row.Value.ToString("##0.00", CultureInfo.InvariantCulture)));
+ }
+
+ return mutations;
+ }
+
+ private static int VariantSuffix(SectorBarVariant variant) => variant switch
+ {
+ SectorBarVariant.Primary => 1,
+ SectorBarVariant.Secondary => 2,
+ _ => throw new LegacySceneDataException("Unknown sector bar variant.")
+ };
+}
+
+public enum GrowthMetricPath
+{
+ First,
+ Second,
+ Third,
+ Fourth
+}
+
+///
+/// Null values represent the legacy empty token and are normalized to zero. The first
+/// value controls visibility exactly as the original rs[0] empty check did.
+///
+public sealed record GrowthMetricSeriesData(
+ GrowthMetricPath Path,
+ IReadOnlyList Values);
+
+public sealed record S5079SceneData(
+ string StockName,
+ IReadOnlyList Series,
+ IReadOnlyList Dates) : ILegacySceneData;
+
+public sealed class S5079SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ private static readonly float[] XPositions = [0, 368, 740, 1110];
+
+ public string BuilderKey => "s5079";
+
+ public IReadOnlyList Build(S5079SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
+ var sourceSeries = LegacySceneBuilderGuard.Count(data.Series, 4, nameof(data.Series));
+ var dates = LegacySceneBuilderGuard.Count(data.Dates, 4, nameof(data.Dates));
+ var normalized = new Dictionary();
+
+ for (var seriesIndex = 0; seriesIndex < sourceSeries.Count; seriesIndex++)
+ {
+ var series = LegacySceneBuilderGuard.NotNull(
+ sourceSeries[seriesIndex],
+ $"Series[{seriesIndex}]");
+ _ = PathNumber(series.Path);
+ var values = LegacySceneBuilderGuard.Count(
+ series.Values,
+ 4,
+ $"Series[{seriesIndex}].Values");
+ var normalizedValues = new double[4];
+ for (var valueIndex = 0; valueIndex < values.Count; valueIndex++)
+ {
+ var value = values[valueIndex] ?? 0d;
+ if (!double.IsFinite(value))
+ {
+ throw new LegacySceneDataException("Growth metric values must be finite.");
+ }
+
+ normalizedValues[valueIndex] = value;
+ }
+
+ if (!normalized.TryAdd(
+ series.Path,
+ new NormalizedGrowthSeries(values[0].HasValue, normalizedValues)))
+ {
+ throw new LegacySceneDataException("Growth metric paths must be unique.");
+ }
+ }
+
+ foreach (var path in Enum.GetValues())
+ {
+ if (!normalized.ContainsKey(path))
+ {
+ throw new LegacySceneDataException("All four growth metric paths are required.");
+ }
+ }
+
+ var validatedDates = new string[dates.Count];
+ for (var index = 0; index < dates.Count; index++)
+ {
+ validatedDates[index] = LegacySceneBuilderGuard.Text(
+ dates[index],
+ $"Dates[{index}]",
+ allowEmpty: true);
+ }
+
+ var allValues = normalized.Values.SelectMany(item => item.Values).ToArray();
+ var maximum = allValues.Max();
+ var minimum = allValues.Min();
+ var range = maximum - minimum;
+ if (!double.IsFinite(range))
+ {
+ throw new LegacySceneDataException("Growth metric range must be finite.");
+ }
+
+ var gap = range / 5d;
+ var mutations = new List(36)
+ {
+ new PlayoutSetValue("title", stockName + " 성장성 지표")
+ };
+ for (var index = 1; index <= 5; index++)
+ {
+ mutations.Add(new PlayoutSetValue(
+ "value" + index,
+ Math.Ceiling(minimum + (gap * index))
+ .ToString(CultureInfo.InvariantCulture)));
+ }
+
+ foreach (var path in Enum.GetValues())
+ {
+ var pathNumber = PathNumber(path);
+ var series = normalized[path];
+ var points = new PlayoutPoint[4];
+ for (var index = 0; index < points.Length; index++)
+ {
+ var y = range == 0d
+ ? 0f
+ : LegacyChartSafety.FiniteFloat(
+ (((360d * (series.Values[index] - minimum) / range) - 180d) * 0.8d),
+ "Growth metric Y position");
+ points[index] = new PlayoutPoint(XPositions[index], y, 0);
+ }
+
+ AppendLegacyGrowthPath(
+ mutations,
+ pathNumber,
+ series.IsVisible,
+ points);
+ }
+
+ for (var index = 1; index <= validatedDates.Length; index++)
+ {
+ mutations.Add(new PlayoutSetValue("date" + index, validatedDates[index - 1]));
+ }
+
+ return mutations;
+ }
+
+ private static void AppendLegacyGrowthPath(
+ ICollection mutations,
+ int pathNumber,
+ bool isVisible,
+ IReadOnlyList points)
+ {
+ var objectName = "path" + pathNumber;
+ if (pathNumber != 3)
+ {
+ mutations.Add(new PlayoutSetVisible(objectName, false));
+ if (isVisible)
+ {
+ // The original sets visibility once per point inside its AddPathPoint loop.
+ for (var index = 0; index < points.Count; index++)
+ {
+ mutations.Add(new PlayoutSetVisible(objectName, true));
+ }
+ }
+ }
+
+ // PlayoutSetPathPoints expands to Begin/Clear/Add*/End in the adapter.
+ mutations.Add(new PlayoutSetPathPoints(objectName, points));
+ if (pathNumber == 3)
+ {
+ mutations.Add(new PlayoutSetVisible(objectName, isVisible));
+ }
+ }
+
+ private static int PathNumber(GrowthMetricPath path) => path switch
+ {
+ GrowthMetricPath.First => 1,
+ GrowthMetricPath.Second => 2,
+ GrowthMetricPath.Third => 3,
+ GrowthMetricPath.Fourth => 4,
+ _ => throw new LegacySceneDataException("Unknown growth metric path.")
+ };
+
+ private sealed record NormalizedGrowthSeries(bool IsVisible, double[] Values);
+}
+
+public sealed record QuarterlySalesData(string Quarter, int Value);
+
+public sealed record S5080SceneData(
+ string StockName,
+ IReadOnlyList Quarters) : ILegacySceneData;
+
+public sealed class S5080SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5080";
+
+ public IReadOnlyList Build(S5080SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
+ var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters));
+ var validated = new QuarterlySalesData[quarters.Count];
+ var mutations = new List(38)
+ {
+ new PlayoutSetValue("title", stockName + " 매출액")
+ };
+
+ for (var index = 0; index < quarters.Count; index++)
+ {
+ var quarter = LegacySceneBuilderGuard.NotNull(
+ quarters[index],
+ $"Quarters[{index}]");
+ validated[index] = quarter;
+ mutations.Add(new PlayoutSetValue(
+ "quarter" + (index + 1),
+ LegacySceneBuilderGuard.Text(
+ quarter.Quarter,
+ $"Quarters[{index}].Quarter")));
+ }
+
+ for (var index = 1; index <= validated.Length; index++)
+ {
+ var value = validated[index - 1].Value;
+ var variant = value < 0 ? 2 : 1;
+ mutations.Add(new PlayoutSetVisible($"barG{index}_1", false));
+ mutations.Add(new PlayoutSetVisible($"barG{index}_2", false));
+ mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true));
+ mutations.Add(new PlayoutSetValue(
+ $"value{index}_{variant}",
+ value.ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+
+ var maximum = validated.Max(item => item.Value);
+ var minimum = validated.Min(item => item.Value);
+ var allPositive = maximum > 0 && minimum > 0;
+ var allNegative = maximum < 0 && minimum < 0;
+ var mixedDenominator = (double)maximum + Math.Abs((double)minimum);
+ var center = allPositive
+ ? -240f
+ : allNegative
+ ? 115f
+ : mixedDenominator == 0d
+ ? -250f
+ : LegacyChartSafety.FiniteFloat(
+ (355d * Math.Abs((double)minimum) / mixedDenominator) - 250d,
+ "Quarterly sales center");
+ mutations.Add(new PlayoutSetPositionKey(
+ "centerbar",
+ 0,
+ 0,
+ center,
+ 0,
+ PlayoutVectorComponents.Y));
+
+ var denominator = allPositive
+ ? (double)maximum
+ : allNegative
+ ? (double)minimum
+ : (double)maximum - minimum;
+ for (var index = 1; index <= validated.Length; index++)
+ {
+ var value = validated[index - 1].Value;
+ var scale = denominator == 0d
+ ? 0f
+ : LegacyChartSafety.FiniteFloat(
+ 1.8d * Math.Abs(value / denominator),
+ "Quarterly sales scale");
+ var variant = value < 0 ? 2 : 1;
+ mutations.Add(new PlayoutSetScale(
+ $"bar{index}_{variant}",
+ 0,
+ scale,
+ 0,
+ PlayoutVectorComponents.Y));
+ }
+
+ return mutations;
+ }
+}
+
+public enum InvestorFlowParticipant
+{
+ Individual,
+ Institution,
+ Foreign
+}
+
+public enum InvestorFlowMarket
+{
+ Kospi,
+ Kosdaq,
+ Kospi200
+}
+
+public sealed record InvestorFlowSummaryData(
+ InvestorFlowMarket Market,
+ int Value);
+
+public sealed record InvestorFlowTrendPoint(
+ InvestorFlowMarket Market,
+ int Value,
+ TimeOnly DataTime);
+
+public sealed record S5083SceneData(
+ InvestorFlowParticipant Participant,
+ IReadOnlyList Summary,
+ IReadOnlyList TrendPoints) : ILegacySceneData;
+
+public sealed class S5083SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5083";
+
+ public IReadOnlyList Build(S5083SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var title = data.Participant switch
+ {
+ InvestorFlowParticipant.Individual => "개인 매매동향",
+ InvestorFlowParticipant.Institution => "기관 매매동향",
+ InvestorFlowParticipant.Foreign => "외국인 매매동향",
+ _ => throw new LegacySceneDataException("Unknown investor flow participant.")
+ };
+ var summary = LegacySceneBuilderGuard.Count(data.Summary, 3, nameof(data.Summary));
+ var trendPoints = LegacySceneBuilderGuard.Range(
+ data.TrendPoints,
+ 3,
+ 10_000,
+ nameof(data.TrendPoints));
+
+ var seenSummaryMarkets = new HashSet();
+ foreach (var row in summary)
+ {
+ var validated = LegacySceneBuilderGuard.NotNull(row, nameof(data.Summary));
+ _ = MarketSlot(validated.Market);
+ if (!seenSummaryMarkets.Add(validated.Market))
+ {
+ throw new LegacySceneDataException("Investor flow summary markets must be unique.");
+ }
+ }
+
+ var grouped = new Dictionary>
+ {
+ [InvestorFlowMarket.Kospi] = [],
+ [InvestorFlowMarket.Kosdaq] = [],
+ [InvestorFlowMarket.Kospi200] = []
+ };
+ var maximum = 0d;
+ var minimum = 0d;
+ foreach (var point in trendPoints)
+ {
+ var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints));
+ _ = MarketSlot(validated.Market);
+ grouped[validated.Market].Add(validated.Value);
+ maximum = Math.Max(maximum, validated.Value);
+ minimum = Math.Min(minimum, validated.Value);
+ }
+
+ if (grouped.Values.Any(values => values.Count == 0))
+ {
+ throw new LegacySceneDataException("Every investor flow market requires trend data.");
+ }
+
+ var scale = LegacyIntradayChartScale.Create(maximum, minimum);
+ var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime);
+ var mutations = new List(8)
+ {
+ new PlayoutSetValue("title", title)
+ };
+ foreach (var row in summary)
+ {
+ mutations.Add(new PlayoutSetValue(
+ "price" + MarketSlot(row.Market),
+ row.Value.ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+
+ mutations.Add(new PlayoutSetPosition(
+ "baseline",
+ 0,
+ scale.BaselinePosition,
+ 0,
+ PlayoutVectorComponents.Y));
+
+ var kospi = grouped[InvestorFlowMarket.Kospi];
+ mutations.Add(new PlayoutSetPathPoints(
+ "path1",
+ BuildS5083Path(kospi, lineWidth, scale, S5083XMode.FirstPath, kospi.Count)));
+ mutations.Add(new PlayoutSetPathPoints(
+ "path2",
+ BuildS5083Path(
+ grouped[InvestorFlowMarket.Kosdaq],
+ lineWidth,
+ scale,
+ S5083XMode.FollowingPath,
+ kospi.Count)));
+ mutations.Add(new PlayoutSetPathPoints(
+ "path3",
+ BuildS5083Path(
+ grouped[InvestorFlowMarket.Kospi200],
+ lineWidth,
+ scale,
+ S5083XMode.FollowingPath,
+ grouped[InvestorFlowMarket.Kospi200].Count)));
+ return mutations;
+ }
+
+ private static IReadOnlyList BuildS5083Path(
+ IReadOnlyList values,
+ double lineWidth,
+ LegacyIntradayChartScale scale,
+ S5083XMode xMode,
+ int denominatorCount)
+ {
+ var points = new List(values.Count + 1)
+ {
+ new(0, scale.Baseline, 0)
+ };
+ for (var index = 0; index < values.Count; index++)
+ {
+ double x;
+ if (xMode == S5083XMode.FirstPath)
+ {
+ x = values.Count == 1
+ ? 0d
+ : lineWidth / (values.Count - 1d) * index;
+ }
+ else
+ {
+ x = lineWidth / denominatorCount * (index + 1d);
+ }
+
+ points.Add(new PlayoutPoint(
+ LegacyChartSafety.FiniteFloat(x, "Investor flow X position"),
+ scale.Y(values[index]),
+ 0));
+ }
+
+ return points;
+ }
+
+ private static int MarketSlot(InvestorFlowMarket market) => market switch
+ {
+ InvestorFlowMarket.Kospi => 1,
+ InvestorFlowMarket.Kosdaq => 2,
+ InvestorFlowMarket.Kospi200 => 3,
+ _ => throw new LegacySceneDataException("Unknown investor flow market.")
+ };
+
+ private enum S5083XMode
+ {
+ FirstPath,
+ FollowingPath
+ }
+}
+
+public enum InvestorFlowIndex
+{
+ Kospi,
+ Kosdaq
+}
+
+public sealed record InvestorFlowTotalsData(
+ int Individual,
+ int Foreign,
+ int Institution);
+
+public sealed record InvestorFlowIndexTrendPoint(
+ int Individual,
+ int Foreign,
+ int Institution,
+ TimeOnly DataTime);
+
+public sealed record S5084SceneData(
+ InvestorFlowIndex Index,
+ InvestorFlowTotalsData Totals,
+ IReadOnlyList TrendPoints) : ILegacySceneData;
+
+public sealed class S5084SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5084";
+
+ public IReadOnlyList Build(S5084SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var title = data.Index switch
+ {
+ InvestorFlowIndex.Kospi => "코스피 매매동향",
+ InvestorFlowIndex.Kosdaq => "코스닥 매매동향",
+ _ => throw new LegacySceneDataException("Unknown investor flow index.")
+ };
+ var totals = LegacySceneBuilderGuard.NotNull(data.Totals, nameof(data.Totals));
+ var trendPoints = LegacySceneBuilderGuard.Range(
+ data.TrendPoints,
+ 1,
+ 10_000,
+ nameof(data.TrendPoints));
+
+ var maximum = 0d;
+ var minimum = 0d;
+ foreach (var point in trendPoints)
+ {
+ var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints));
+ maximum = Math.Max(
+ maximum,
+ Math.Max(validated.Individual, Math.Max(validated.Foreign, validated.Institution)));
+ minimum = Math.Min(
+ minimum,
+ Math.Min(validated.Individual, Math.Min(validated.Foreign, validated.Institution)));
+ }
+
+ var scale = LegacyIntradayChartScale.Create(maximum, minimum);
+ var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime);
+ var mutations = new List(8)
+ {
+ new PlayoutSetValue("title", title),
+ new PlayoutSetValue(
+ "price1",
+ totals.Individual.ToString("#,##0", CultureInfo.InvariantCulture)),
+ new PlayoutSetValue(
+ "price2",
+ totals.Foreign.ToString("#,##0", CultureInfo.InvariantCulture)),
+ new PlayoutSetValue(
+ "price3",
+ totals.Institution.ToString("#,##0", CultureInfo.InvariantCulture)),
+ new PlayoutSetPosition(
+ "baseline",
+ 0,
+ scale.BaselinePosition,
+ 0,
+ PlayoutVectorComponents.Y),
+ new PlayoutSetPathPoints(
+ "path1",
+ BuildS5084Path(trendPoints.Select(item => item.Individual).ToArray(), lineWidth, scale)),
+ new PlayoutSetPathPoints(
+ "path2",
+ BuildS5084Path(trendPoints.Select(item => item.Foreign).ToArray(), lineWidth, scale)),
+ new PlayoutSetPathPoints(
+ "path3",
+ BuildS5084Path(trendPoints.Select(item => item.Institution).ToArray(), lineWidth, scale))
+ };
+ return mutations;
+ }
+
+ private static IReadOnlyList BuildS5084Path(
+ IReadOnlyList values,
+ double lineWidth,
+ LegacyIntradayChartScale scale)
+ {
+ var points = new List(values.Count + 1)
+ {
+ new(0, scale.Baseline, 0)
+ };
+ for (var index = 0; index < values.Count; index++)
+ {
+ points.Add(new PlayoutPoint(
+ LegacyChartSafety.FiniteFloat(
+ lineWidth / values.Count * (index + 1d),
+ "Index flow X position"),
+ scale.Y(values[index]),
+ 0));
+ }
+
+ return points;
+ }
+}
+
+internal sealed record LegacyIntradayChartScale(
+ double Minimum,
+ double Range,
+ float Baseline,
+ float BaselinePosition)
+{
+ private const double GraphMinimum = -138d;
+ private const double GraphHeight = 266d;
+ private const double GraphOffset = 48d;
+ private const double DegenerateMidpoint = -5d;
+ private const double FullLineWidth = 1212.26d;
+
+ public static LegacyIntradayChartScale Create(double maximum, double minimum)
+ {
+ var range = maximum - minimum;
+ if (!double.IsFinite(maximum) || !double.IsFinite(minimum) || !double.IsFinite(range))
+ {
+ throw new LegacySceneDataException("Intraday chart range must be finite.");
+ }
+
+ var baseline = range == 0d
+ ? DegenerateMidpoint
+ : GraphMinimum + ((0d - minimum) / range * GraphHeight);
+ var baselinePosition = Convert.ToInt32(GraphOffset + baseline);
+ return new LegacyIntradayChartScale(
+ minimum,
+ range,
+ LegacyChartSafety.FiniteFloat(baseline, "Intraday baseline"),
+ baselinePosition);
+ }
+
+ public static double LineWidth(TimeOnly time)
+ {
+ var minutes = (time.Hour * 60) + time.Minute;
+ if (minutes >= 930)
+ {
+ return FullLineWidth;
+ }
+
+ // The legacy formula becomes negative before 09:00. Clamp to the physical graph.
+ var progress = Math.Clamp((minutes - 540d) / 390d, 0d, 1d);
+ return FullLineWidth * progress;
+ }
+
+ public float Y(double value)
+ {
+ var y = Range == 0d
+ ? DegenerateMidpoint
+ : GraphMinimum + ((value - Minimum) / Range * GraphHeight);
+ return LegacyChartSafety.FiniteFloat(y, "Intraday Y position");
+ }
+}
+
+internal static class LegacyChartSafety
+{
+ public static float FiniteFloat(double value, string name)
+ {
+ if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue)
+ {
+ throw new LegacySceneDataException(name + " must be a finite float.");
+ }
+
+ return (float)value;
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs
new file mode 100644
index 0000000..46e0fa2
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneDataLoaders.cs
@@ -0,0 +1,909 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record S5078SceneDataRequest(SectorIndexMarket Market);
+
+public sealed class S5078SceneDataLoader
+{
+ private static readonly string[] Columns =
+ ["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5078SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5078SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var spec = S5078QueryFactory.Create(request.Market);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5078_SECTOR_INDEX",
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ var rows = ChartSceneDataLoaderReader.Rows(table, Columns, 10, 10);
+ var mapped = new SectorIndexBarData[rows.Count];
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var row = rows[index];
+ // These values were returned and displayed by the original query even though
+ // only RATE participates in the K3D mutation. Validate them fail-closed.
+ _ = ChartSceneDataLoaderReader.Double(row, "PART_INDEX");
+ _ = ChartSceneDataLoaderReader.Double(row, "PART_CHANGE");
+ var changeType = ChartSceneDataLoaderReader.Text(
+ row,
+ "CHG_TYPE",
+ allowEmpty: true).Trim();
+ mapped[index] = new SectorIndexBarData(
+ ChartSceneDataLoaderReader.Text(row, "CATEGORY"),
+ ChartSceneDataLoaderReader.Double(row, "RATE"),
+ changeType == "-"
+ ? SectorBarVariant.Primary
+ : SectorBarVariant.Secondary);
+ }
+
+ var data = new S5078SceneData(request.Market, mapped);
+ _ = new S5078SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+internal static class S5078QueryFactory
+{
+ private const string KospiQuery = """
+ SELECT
+ b.f_part_name CATEGORY,
+ a.F_PART_IDX / 100 PART_INDEX,
+ a.F_CHG_TYPE CHG_TYPE,
+ a.F_PART_CHG / 100 PART_CHANGE,
+ ROUND(
+ a.f_part_chg /
+ DECODE(
+ a.f_chg_type,
+ '+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
+ '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)),
+ 2) RATE
+ FROM t_index a, t_part b
+ WHERE a.f_part_code = b.f_part_code
+ AND a.f_part_code IN
+ ('013', '009', '011', '016', '018', '021', '008', '007', '015', '005')
+ ORDER BY a.f_part_code
+ """;
+
+ private const string KosdaqQuery = """
+ SELECT
+ b.f_part_name CATEGORY,
+ a.F_PART_IDX / 100 PART_INDEX,
+ a.F_CHG_TYPE CHG_TYPE,
+ a.F_PART_CHG / 100 PART_CHANGE,
+ ROUND(
+ a.f_part_chg /
+ DECODE(
+ a.f_chg_type,
+ '+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
+ '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)),
+ 2) RATE
+ FROM t_kosdaq_index a, t_kosdaq_part b
+ WHERE a.f_part_code = b.f_part_code
+ AND a.f_part_code IN
+ ('159', '160', '066', '154', '065', '031', '029', '070', '024', '153')
+ ORDER BY a.f_part_code
+ """;
+
+ private const string DowQuery = """
+ SELECT
+ a.f_input_name CATEGORY,
+ ROUND(b.f_last, 2) PART_INDEX,
+ b.f_sign CHG_TYPE,
+ ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
+ ROUND(b.f_rate, 2) RATE
+ FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
+ WHERE a.f_symb = b.f_symb
+ AND a.f_symb IN
+ ('DJI@DJINET', 'DJI@DJUSAU', 'DJI@DJUSCH', 'DJI@DJUSNF', 'DJI@DJUSCN',
+ 'DJI@DJT', 'DJI@DJU', 'DJI@DJUSFB', 'DJI@DJUSCFT', 'DJI@DJUSEE')
+ """;
+
+ private const string NasdaqQuery = """
+ SELECT
+ a.f_input_name CATEGORY,
+ ROUND(b.f_last, 2) PART_INDEX,
+ b.f_sign CHG_TYPE,
+ ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
+ ROUND(b.f_rate, 2) RATE
+ FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
+ WHERE a.f_symb = b.f_symb
+ AND a.f_symb IN
+ ('NAS@CXBT', 'NAS@NDX', 'NAS@NQSSSE', 'NAS@IXBK', 'NAS@IXIS',
+ 'NAS@IXUT', 'NAS@IXK', 'NAS@NBI', 'NAS@IXF', 'NAS@NDXT')
+ """;
+
+ private const string StandardAndPoorQuery = """
+ SELECT
+ a.f_input_name CATEGORY,
+ ROUND(b.f_last, 2) PART_INDEX,
+ b.f_sign CHG_TYPE,
+ ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
+ ROUND(b.f_rate, 2) RATE
+ FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
+ WHERE a.f_symb = b.f_symb
+ AND a.f_symb IN
+ ('SPI@S5ENRS', 'SPI@S5RLST', 'SPI@S5AUCO', 'SPI@S5INFT', 'SPI@S5COND',
+ 'SPI@S5INSU', 'SPI@S5ELUTX', 'SPI@S5HLTH', 'SPI@S5FINL', 'SPI@S5TELS')
+ """;
+
+ public static DataQuerySpec Create(SectorIndexMarket market)
+ {
+ var sql = market switch
+ {
+ SectorIndexMarket.Dow => DowQuery,
+ SectorIndexMarket.Nasdaq => NasdaqQuery,
+ SectorIndexMarket.StandardAndPoor => StandardAndPoorQuery,
+ SectorIndexMarket.Kospi => KospiQuery,
+ SectorIndexMarket.Kosdaq => KosdaqQuery,
+ _ => throw ChartSceneDataLoaderReader.InvalidRequest()
+ };
+ return ChartSceneDataLoaderReader.Spec(sql);
+ }
+}
+
+public sealed record S5079SceneDataRequest(string StockName);
+
+public sealed class S5079SceneDataLoader
+{
+ private static readonly string[] Columns =
+ ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"];
+
+ private const string Query = """
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE
+ FROM INPUT_GROW
+ WHERE STOCK_NAME = :stockName
+ """;
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5079SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5079SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var stockName = ChartSceneDataLoaderReader.Selection(request.StockName);
+ var spec = ChartSceneDataLoaderReader.Spec(
+ Query,
+ new DataQueryParameter("stockName", stockName, DbType.String));
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "INPUT_GROW",
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ var row = ChartSceneDataLoaderReader.SingleRow(table, Columns);
+ var series = new GrowthMetricSeriesData[4];
+ foreach (var path in Enum.GetValues())
+ {
+ var column = "GUSUNG_" + (((int)path) + 1).ToString(CultureInfo.InvariantCulture);
+ series[(int)path] = new GrowthMetricSeriesData(
+ path,
+ ParseGrowthSeries(ChartSceneDataLoaderReader.Text(row, column, allowEmpty: true)));
+ }
+
+ var dates = ChartSceneDataLoaderReader.Text(row, "BASE_DATE", allowEmpty: true)
+ .Split('_', StringSplitOptions.None);
+ if (dates.Length != 4)
+ {
+ throw ChartSceneDataLoaderReader.InvalidResult();
+ }
+
+ for (var index = 0; index < dates.Length; index++)
+ {
+ _ = LegacySceneBuilderGuard.Text(
+ dates[index],
+ $"Dates[{index}]",
+ allowEmpty: true);
+ }
+
+ var data = new S5079SceneData(
+ ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"),
+ series,
+ dates);
+ _ = new S5079SceneMutationBuilder().Build(data);
+ return data;
+ }
+
+ private static IReadOnlyList ParseGrowthSeries(string value)
+ {
+ var tokens = value.Split('_', StringSplitOptions.None);
+ if (tokens.Length != 4)
+ {
+ throw ChartSceneDataLoaderReader.InvalidResult();
+ }
+
+ var values = new double?[4];
+ for (var index = 0; index < tokens.Length; index++)
+ {
+ if (tokens[index].Length == 0)
+ {
+ values[index] = null;
+ continue;
+ }
+
+ if (!double.TryParse(
+ tokens[index],
+ NumberStyles.Float | NumberStyles.AllowThousands,
+ CultureInfo.InvariantCulture,
+ out var parsed) ||
+ !double.IsFinite(parsed))
+ {
+ throw ChartSceneDataLoaderReader.InvalidResult();
+ }
+
+ values[index] = parsed;
+ }
+
+ return values;
+ }
+}
+
+public sealed record S5080SceneDataRequest(string StockName);
+
+public sealed class S5080SceneDataLoader
+{
+ private static readonly string[] Columns =
+ [
+ "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3",
+ "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"
+ ];
+
+ private const string Query = """
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6
+ FROM INPUT_SELL
+ WHERE STOCK_NAME = :stockName
+ """;
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5080SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5080SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var stockName = ChartSceneDataLoaderReader.Selection(request.StockName);
+ var spec = ChartSceneDataLoaderReader.Spec(
+ Query,
+ new DataQueryParameter("stockName", stockName, DbType.String));
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "INPUT_SELL",
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ var row = ChartSceneDataLoaderReader.SingleRow(table, Columns);
+ var quarters = new QuarterlySalesData[6];
+ for (var index = 0; index < quarters.Length; index++)
+ {
+ var raw = ChartSceneDataLoaderReader.Text(
+ row,
+ "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture),
+ allowEmpty: true);
+ quarters[index] = ParseQuarter(raw);
+ }
+
+ var data = new S5080SceneData(
+ ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"),
+ quarters);
+ _ = new S5080SceneMutationBuilder().Build(data);
+ return data;
+ }
+
+ private static QuarterlySalesData ParseQuarter(string value)
+ {
+ var tokens = value.Split('_', StringSplitOptions.None);
+ if (tokens.Length != 2 || string.IsNullOrWhiteSpace(tokens[0]))
+ {
+ throw ChartSceneDataLoaderReader.InvalidResult();
+ }
+
+ var number = 0;
+ if (tokens[1].Length != 0 &&
+ !int.TryParse(
+ tokens[1],
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out number))
+ {
+ throw ChartSceneDataLoaderReader.InvalidResult();
+ }
+
+ return new QuarterlySalesData(tokens[0], number);
+ }
+}
+
+public sealed record S5083SceneDataRequest(InvestorFlowParticipant Participant);
+
+public sealed class S5083SceneDataLoader
+{
+ private static readonly string[] SummaryColumns = ["MARKET", "AMOUNT"];
+ private static readonly string[] TrendColumns = ["MARKET", "DATA_TIME", "PRICE"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5083SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5083SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var plan = S5083QueryFactory.Create(request.Participant);
+ var summaryTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5083_SUMMARY",
+ plan.Summary,
+ cancellationToken).ConfigureAwait(false);
+ var trendTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5083_TREND",
+ plan.Trend,
+ cancellationToken).ConfigureAwait(false);
+
+ var summaryRows = ChartSceneDataLoaderReader.Rows(summaryTable, SummaryColumns, 3, 3);
+ var summary = summaryRows
+ .Select(row => new InvestorFlowSummaryData(
+ ChartSceneDataLoaderReader.Market(row, "MARKET"),
+ ChartSceneDataLoaderReader.Int32(row, "AMOUNT")))
+ .ToArray();
+
+ var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 3, 10_000);
+ var trend = trendRows
+ .Select(row => new InvestorFlowTrendPoint(
+ ChartSceneDataLoaderReader.Market(row, "MARKET"),
+ ChartSceneDataLoaderReader.Int32(row, "PRICE"),
+ ChartSceneDataLoaderReader.Time(row, "DATA_TIME")))
+ .ToArray();
+
+ var data = new S5083SceneData(request.Participant, summary, trend);
+ _ = new S5083SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+internal sealed record S5083QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend);
+
+internal static class S5083QueryFactory
+{
+ private const string IndividualSummary = """
+ SELECT '코스피' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE = '8000'
+ UNION
+ SELECT '코스닥' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_kosdaq_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE = '8000'
+ UNION
+ SELECT '코스피200' MARKET,
+ ROUND((SUM(F_SELL_turnover) / 100000000) -
+ (SUM(F_BUY_turnover) / 100000000)) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE = '8000'
+ """;
+
+ private const string IndividualTrend = """
+ SELECT '코스피' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE = '8000'
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ UNION
+ SELECT '코스닥' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
+ FROM t_kosdaq_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE = '8000'
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ UNION
+ SELECT '코스피200' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE = '8000'
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ ORDER BY MARKET, DATA_TIME
+ """;
+
+ private const string InstitutionSummary = """
+ SELECT '코스피' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ GROUP BY f_part_code
+ UNION
+ SELECT '코스닥' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_kosdaq_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ GROUP BY f_part_code
+ UNION
+ SELECT '코스피200' MARKET,
+ ROUND((SUM(F_SELL_turnover) / 100000000) -
+ (SUM(F_BUY_turnover) / 100000000)) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ """;
+
+ private const string InstitutionTrend = """
+ SELECT '코스피' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ UNION
+ SELECT '코스닥' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_kosdaq_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ UNION
+ SELECT '코스피200' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ ORDER BY MARKET, DATA_TIME
+ """;
+
+ private const string ForeignSummary = """
+ SELECT '코스피' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ UNION
+ SELECT '코스닥' MARKET,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
+ FROM t_kosdaq_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ UNION
+ SELECT '코스피200' MARKET,
+ ROUND((SUM(F_SELL_turnover) / 100000000) -
+ (SUM(F_BUY_turnover) / 100000000)) AMOUNT
+ FROM t_invest
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ """;
+
+ private const string ForeignTrend = """
+ SELECT '코스피' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ UNION
+ SELECT '코스닥' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_kosdaq_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ UNION
+ SELECT '코스피200' MARKET,
+ f_data_time DATA_TIME,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
+ FROM t_invest_his
+ WHERE F_PART_CODE = '029'
+ AND F_INVEST_CODE IN ('9000', '9001')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time
+ ORDER BY MARKET, DATA_TIME
+ """;
+
+ public static S5083QueryPlan Create(InvestorFlowParticipant participant)
+ {
+ var queries = participant switch
+ {
+ InvestorFlowParticipant.Individual => (IndividualSummary, IndividualTrend),
+ InvestorFlowParticipant.Institution => (InstitutionSummary, InstitutionTrend),
+ InvestorFlowParticipant.Foreign => (ForeignSummary, ForeignTrend),
+ _ => throw ChartSceneDataLoaderReader.InvalidRequest()
+ };
+ return new S5083QueryPlan(
+ ChartSceneDataLoaderReader.Spec(queries.Item1),
+ ChartSceneDataLoaderReader.Spec(queries.Item2));
+ }
+}
+
+public sealed record S5084SceneDataRequest(InvestorFlowIndex Index);
+
+public sealed class S5084SceneDataLoader
+{
+ private static readonly string[] SummaryColumns =
+ ["INDIVIDUAL", "FOREIGN", "INSTITUTION"];
+ private static readonly string[] TrendColumns =
+ ["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5084SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5084SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var plan = S5084QueryFactory.Create(request.Index);
+ var summaryTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5084_SUMMARY",
+ plan.Summary,
+ cancellationToken).ConfigureAwait(false);
+ var trendTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5084_TREND",
+ plan.Trend,
+ cancellationToken).ConfigureAwait(false);
+ var summaryRow = ChartSceneDataLoaderReader.SingleRow(summaryTable, SummaryColumns);
+ var totals = new InvestorFlowTotalsData(
+ ChartSceneDataLoaderReader.Int32(summaryRow, "INDIVIDUAL"),
+ ChartSceneDataLoaderReader.Int32(summaryRow, "FOREIGN"),
+ ChartSceneDataLoaderReader.Int32(summaryRow, "INSTITUTION"));
+ var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 1, 10_000);
+ var trend = trendRows
+ .Select(row => new InvestorFlowIndexTrendPoint(
+ ChartSceneDataLoaderReader.Int32(row, "INDIVIDUAL"),
+ ChartSceneDataLoaderReader.Int32(row, "FOREIGN"),
+ ChartSceneDataLoaderReader.Int32(row, "INSTITUTION"),
+ ChartSceneDataLoaderReader.Time(row, "DATA_TIME")))
+ .ToArray();
+ var data = new S5084SceneData(request.Index, totals, trend);
+ _ = new S5084SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+internal sealed record S5084QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend);
+
+internal static class S5084QueryFactory
+{
+ private const string KospiSummary = """
+ SELECT
+ SUM(DECODE(
+ f_invest_code,
+ '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0)) INDIVIDUAL,
+ SUM(DECODE(
+ f_invest_code,
+ '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0)) FOREIGN,
+ SUM(DECODE(
+ f_invest_code,
+ '8000', 0,
+ '9000', 0,
+ '9001', 0,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION
+ FROM t_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN
+ ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
+ '8000', '9000', '9001')
+ GROUP BY f_invest_code
+ """;
+
+ private const string KosdaqSummary = """
+ SELECT
+ SUM(DECODE(
+ f_invest_code,
+ '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0)) INDIVIDUAL,
+ SUM(DECODE(
+ f_invest_code,
+ '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0)) FOREIGN,
+ SUM(DECODE(
+ f_invest_code,
+ '8000', 0,
+ '9000', 0,
+ '9001', 0,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION
+ FROM t_kosdaq_invest
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN
+ ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
+ '8000', '9000', '9001')
+ GROUP BY f_invest_code
+ """;
+
+ private const string KospiTrend = """
+ SELECT
+ f_data_time DATA_TIME,
+ SUM(p_1) INDIVIDUAL,
+ SUM(p_2) FOREIGN,
+ SUM(p_3) INSTITUTION
+ FROM (
+ SELECT
+ f_data_time,
+ DECODE(
+ f_invest_code,
+ '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0) p_1,
+ DECODE(
+ f_invest_code,
+ '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0) p_2,
+ DECODE(
+ f_invest_code,
+ '8000', 0,
+ '9000', 0,
+ '9001', 0,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3
+ FROM t_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN
+ ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
+ '8000', '9000', '9001')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time, f_invest_code
+ )
+ GROUP BY f_data_time
+ ORDER BY f_data_time
+ """;
+
+ private const string KosdaqTrend = """
+ SELECT
+ f_data_time DATA_TIME,
+ SUM(p_1) INDIVIDUAL,
+ SUM(p_2) FOREIGN,
+ SUM(p_3) INSTITUTION
+ FROM (
+ SELECT
+ f_data_time,
+ DECODE(
+ f_invest_code,
+ '8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0) p_1,
+ DECODE(
+ f_invest_code,
+ '9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ '9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
+ 0) p_2,
+ DECODE(
+ f_invest_code,
+ '8000', 0,
+ '9000', 0,
+ '9001', 0,
+ ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3
+ FROM t_kosdaq_invest_his
+ WHERE F_PART_CODE = '001'
+ AND F_INVEST_CODE IN
+ ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
+ '8000', '9000', '9001')
+ AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
+ GROUP BY f_data_time, f_invest_code
+ )
+ GROUP BY f_data_time
+ ORDER BY f_data_time
+ """;
+
+ public static S5084QueryPlan Create(InvestorFlowIndex index)
+ {
+ var queries = index switch
+ {
+ InvestorFlowIndex.Kospi => (KospiSummary, KospiTrend),
+ InvestorFlowIndex.Kosdaq => (KosdaqSummary, KosdaqTrend),
+ _ => throw ChartSceneDataLoaderReader.InvalidRequest()
+ };
+ return new S5084QueryPlan(
+ ChartSceneDataLoaderReader.Spec(queries.Item1),
+ ChartSceneDataLoaderReader.Spec(queries.Item2));
+ }
+}
+
+internal static class ChartSceneDataLoaderReader
+{
+ private const int MaximumSelectionLength = 256;
+ private const int MaximumTextLength = 4_096;
+
+ public static DataQuerySpec Spec(string sql, params DataQueryParameter[] parameters)
+ {
+ var spec = new DataQuerySpec(sql, parameters);
+ spec.ValidateFor(DataSourceKind.Oracle);
+ return spec;
+ }
+
+ public static string Selection(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > MaximumSelectionLength ||
+ value.Any(char.IsControl))
+ {
+ throw InvalidRequest();
+ }
+
+ return value;
+ }
+
+ public static DataRow SingleRow(DataTable? table, IReadOnlyList columns) =>
+ Rows(table, columns, 1, 1)[0];
+
+ public static IReadOnlyList Rows(
+ DataTable? table,
+ IReadOnlyList columns,
+ int minimum,
+ int maximum)
+ {
+ if (table is null || table.Columns.Count != columns.Count ||
+ table.Rows.Count < minimum || table.Rows.Count > maximum)
+ {
+ throw InvalidResult();
+ }
+
+ for (var index = 0; index < columns.Count; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ columns[index],
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw InvalidResult();
+ }
+ }
+
+ return table.Rows.Cast().ToArray();
+ }
+
+ public static string Text(DataRow row, string column, bool allowEmpty = false)
+ {
+ if (row[column] is not { } value || value is DBNull)
+ {
+ throw InvalidResult();
+ }
+
+ string text;
+ try
+ {
+ text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
+ }
+ catch (Exception exception) when (exception is FormatException or InvalidCastException)
+ {
+ throw InvalidResult();
+ }
+
+ if (text.Length > MaximumTextLength || text.Any(char.IsControl) ||
+ (!allowEmpty && string.IsNullOrWhiteSpace(text)))
+ {
+ throw InvalidResult();
+ }
+
+ return text;
+ }
+
+ public static double Double(DataRow row, string column)
+ {
+ try
+ {
+ var value = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture);
+ return double.IsFinite(value) ? value : throw InvalidResult();
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static int Int32(DataRow row, string column)
+ {
+ try
+ {
+ var value = Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture);
+ if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue)
+ {
+ throw InvalidResult();
+ }
+
+ return decimal.ToInt32(value);
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static InvestorFlowMarket Market(DataRow row, string column) =>
+ Text(row, column).Trim() switch
+ {
+ "코스피" => InvestorFlowMarket.Kospi,
+ "코스닥" => InvestorFlowMarket.Kosdaq,
+ "코스피200" => InvestorFlowMarket.Kospi200,
+ _ => throw InvalidResult()
+ };
+
+ public static TimeOnly Time(DataRow row, string column)
+ {
+ var value = Value(row, column);
+ if (value is DateTime dateTime)
+ {
+ return TimeOnly.FromDateTime(dateTime);
+ }
+
+ var text = Text(row, column).Trim();
+ if (text.Length < 4)
+ {
+ throw InvalidResult();
+ }
+
+ var timeText = text[^4..];
+ if (!TimeOnly.TryParseExact(
+ timeText,
+ "HHmm",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var time))
+ {
+ throw InvalidResult();
+ }
+
+ return time;
+ }
+
+ private static object Value(DataRow row, string column) =>
+ row[column] is { } value && value is not DBNull ? value : throw InvalidResult();
+
+ public static LegacySceneDataException InvalidRequest() =>
+ new("The chart scene data request is unsupported or invalid.");
+
+ public static LegacySceneDataException InvalidResult() =>
+ new("The chart scene query returned invalid data or an unexpected schema.");
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs
new file mode 100644
index 0000000..b3a6880
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldLegacyRequestResolver.cs
@@ -0,0 +1,335 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Closed result for MainForm's s5026/s5029/s5086/s50860/s5087 dispatch.
+///
+public abstract record LegacyComparisonAndYieldSceneLoadRequest(string BuilderKey);
+
+public sealed record LegacyS5026SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
+ : LegacyComparisonAndYieldSceneLoadRequest("s5026");
+
+public sealed record LegacyS5029SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
+ : LegacyComparisonAndYieldSceneLoadRequest("s5029");
+
+public sealed record LegacyS5086SceneLoadRequest(YieldSceneLoadRequest Request)
+ : LegacyComparisonAndYieldSceneLoadRequest("s5086");
+
+public sealed record LegacyS50860SceneLoadRequest(YieldSceneLoadRequest Request)
+ : LegacyComparisonAndYieldSceneLoadRequest("s50860");
+
+public sealed record LegacyS5087SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
+ : LegacyComparisonAndYieldSceneLoadRequest("s5087");
+
+///
+/// Converts the legacy playlist fields to the closed comparison/yield loader requests.
+/// LegacySceneSelection maps to MainForm as GroupCode=code, Subject=jongmok,
+/// GraphicType=forCutInfo, Subtype=sub and DataCode=dataCode. The five source
+/// constructors use only code, jongmok and sub.
+///
+public sealed class ComparisonAndYieldLegacyRequestResolver
+{
+ private readonly IDataQueryExecutor _executor;
+
+ public ComparisonAndYieldLegacyRequestResolver(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task ResolveAsync(
+ LegacyPlaylistEntry entry,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.CutCode is not ("5026" or "5029" or "5086" or "50860" or "5087"))
+ {
+ throw InvalidSelection();
+ }
+
+ var selection = entry.Selection ?? throw InvalidSelection();
+ var subject = Required(selection.Subject);
+
+ return entry.CutCode switch
+ {
+ "5026" => new LegacyS5026SceneLoadRequest(
+ await ResolvePairAsync(
+ subject,
+ ComparisonGraphPeriod.FiveDays,
+ cancellationToken).ConfigureAwait(false)),
+ "5029" => new LegacyS5029SceneLoadRequest(
+ await ResolvePairAsync(
+ subject,
+ ParseComparisonPeriod(selection.Subtype),
+ cancellationToken).ConfigureAwait(false)),
+ "5087" => new LegacyS5087SceneLoadRequest(
+ await ResolvePairAsync(
+ subject,
+ ComparisonGraphPeriod.FiveDays,
+ cancellationToken).ConfigureAwait(false)),
+ "5086" => new LegacyS5086SceneLoadRequest(
+ await ResolveYieldAsync(
+ selection,
+ subject,
+ requireQuotedFx: false,
+ cancellationToken).ConfigureAwait(false)),
+ "50860" => new LegacyS50860SceneLoadRequest(
+ await ResolveYieldAsync(
+ selection,
+ subject,
+ requireQuotedFx: true,
+ cancellationToken).ConfigureAwait(false)),
+ _ => throw InvalidSelection()
+ };
+ }
+
+ private async Task ResolvePairAsync(
+ string subject,
+ ComparisonGraphPeriod period,
+ CancellationToken cancellationToken)
+ {
+ var stockNames = SplitPair(subject);
+ var first = await ResolveStockAsync(stockNames[0], cancellationToken)
+ .ConfigureAwait(false);
+ var second = await ResolveStockAsync(stockNames[1], cancellationToken)
+ .ConfigureAwait(false);
+ return new ComparisonPairSceneLoadRequest(period, first, second);
+ }
+
+ private async Task ResolveYieldAsync(
+ LegacySceneSelection selection,
+ string subject,
+ bool requireQuotedFx,
+ CancellationToken cancellationToken)
+ {
+ var period = ParseYieldPeriod(selection.Subtype);
+ var fxKind = TryParseFx(subject);
+ if (fxKind.HasValue)
+ {
+ return new YieldSceneLoadRequest(
+ period,
+ new YieldGraphInstrument(fxKind.Value));
+ }
+
+ // The source s50860 reads quote cells which exist only in the FX result shape.
+ // Reject every other legacy branch before performing a stock-master lookup.
+ if (requireQuotedFx)
+ {
+ throw InvalidSelection();
+ }
+
+ var groupCode = selection.GroupCode?.Trim() ?? string.Empty;
+ if (subject == "지수")
+ {
+ var index = groupCode switch
+ {
+ "코스피" => YieldGraphInstrumentKind.KospiIndex,
+ "코스닥" => YieldGraphInstrumentKind.KosdaqIndex,
+ "코스피200" => YieldGraphInstrumentKind.Kospi200Index,
+ "KRX100" => YieldGraphInstrumentKind.Krx100Index,
+ _ => throw InvalidSelection()
+ };
+ return new YieldSceneLoadRequest(period, new YieldGraphInstrument(index));
+ }
+
+ if (groupCode.Contains("업종", StringComparison.Ordinal))
+ {
+ var industry = groupCode.Contains("코스피", StringComparison.Ordinal)
+ ? YieldGraphInstrumentKind.KospiIndustry
+ : YieldGraphInstrumentKind.KosdaqIndustry;
+ return new YieldSceneLoadRequest(
+ period,
+ new YieldGraphInstrument(industry, subject));
+ }
+
+ var stock = await ResolveStockAsync(subject, cancellationToken).ConfigureAwait(false);
+ var stockKind = stock.Market switch
+ {
+ ComparisonEquityMarket.Kospi => YieldGraphInstrumentKind.KospiStock,
+ ComparisonEquityMarket.Kosdaq => YieldGraphInstrumentKind.KosdaqStock,
+ _ => throw InvalidSelection()
+ };
+ return new YieldSceneLoadRequest(
+ period,
+ new YieldGraphInstrument(stockKind, stock.StockName));
+ }
+
+ private async Task ResolveStockAsync(
+ string rawName,
+ CancellationToken cancellationToken)
+ {
+ var stockName = Required(rawName);
+ ComparisonEquityMarket? market = null;
+ if (await StockExistsAsync(
+ ComparisonEquityMarket.Kospi,
+ stockName,
+ cancellationToken).ConfigureAwait(false))
+ {
+ market = ComparisonEquityMarket.Kospi;
+ }
+
+ // MainForm scans the KOSDAQ cache after the KOSPI cache, so a later
+ // KOSDAQ match intentionally takes precedence for an overlapping name.
+ if (await StockExistsAsync(
+ ComparisonEquityMarket.Kosdaq,
+ stockName,
+ cancellationToken).ConfigureAwait(false))
+ {
+ market = ComparisonEquityMarket.Kosdaq;
+ }
+
+ return market.HasValue
+ ? new ComparisonStockSelection(market.Value, stockName)
+ : throw InvalidSelection();
+ }
+
+ private async Task StockExistsAsync(
+ ComparisonEquityMarket market,
+ string stockName,
+ CancellationToken cancellationToken)
+ {
+ var query = StockMasterQuery(market, stockName);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ query.TableName,
+ query.Spec,
+ cancellationToken).ConfigureAwait(false);
+ if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 ||
+ !string.Equals(
+ table.Columns[0].ColumnName,
+ "MATCH_COUNT",
+ StringComparison.OrdinalIgnoreCase) ||
+ table.Rows[0][0] is null or DBNull)
+ {
+ throw InvalidResult();
+ }
+
+ decimal count;
+ try
+ {
+ count = Convert.ToDecimal(table.Rows[0][0], CultureInfo.InvariantCulture);
+ }
+ catch (Exception exception) when (exception is
+ FormatException or InvalidCastException or OverflowException)
+ {
+ _ = exception;
+ throw InvalidResult();
+ }
+
+ if (count != decimal.Truncate(count))
+ {
+ throw InvalidResult();
+ }
+
+ return count switch
+ {
+ 0m => false,
+ 1m => true,
+ _ => throw InvalidResult()
+ };
+ }
+
+ private static StockMasterLookup StockMasterQuery(
+ ComparisonEquityMarket market,
+ string stockName)
+ {
+ var parameter = new DataQueryParameter("stockName", stockName, DbType.String);
+ return market switch
+ {
+ ComparisonEquityMarket.Kospi => new StockMasterLookup(
+ "SCENE_COMPARISON_RESOLVE_KOSPI_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " +
+ "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
+ [parameter])),
+ ComparisonEquityMarket.Kosdaq => new StockMasterLookup(
+ "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " +
+ "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
+ [parameter])),
+ _ => throw InvalidSelection()
+ };
+ }
+
+ private static ComparisonGraphPeriod ParseComparisonPeriod(string value) =>
+ Required(value) switch
+ {
+ "일봉" => ComparisonGraphPeriod.Daily,
+ "5일" => ComparisonGraphPeriod.FiveDays,
+ "1개월" => ComparisonGraphPeriod.OneMonth,
+ "3개월" => ComparisonGraphPeriod.ThreeMonths,
+ "6개월" => ComparisonGraphPeriod.SixMonths,
+ "12개월" => ComparisonGraphPeriod.TwelveMonths,
+ _ => throw InvalidSelection()
+ };
+
+ private static YieldGraphPeriod ParseYieldPeriod(string value) =>
+ Required(value) switch
+ {
+ "5일" => YieldGraphPeriod.FiveDays,
+ "20일" => YieldGraphPeriod.TwentyDays,
+ "60일" => YieldGraphPeriod.SixtyDays,
+ "120일" => YieldGraphPeriod.OneHundredTwentyDays,
+ "240일" => YieldGraphPeriod.TwoHundredFortyDays,
+ _ => throw InvalidSelection()
+ };
+
+ private static YieldGraphInstrumentKind? TryParseFx(string subject)
+ {
+ if (subject.Contains("원달러", StringComparison.Ordinal))
+ {
+ return YieldGraphInstrumentKind.WonDollar;
+ }
+
+ if (subject.Contains("원엔", StringComparison.Ordinal))
+ {
+ return YieldGraphInstrumentKind.WonYen;
+ }
+
+ if (subject.Contains("원위엔", StringComparison.Ordinal))
+ {
+ return YieldGraphInstrumentKind.WonYuan;
+ }
+
+ if (subject.Contains("원유로", StringComparison.Ordinal))
+ {
+ return YieldGraphInstrumentKind.WonEuro;
+ }
+
+ return null;
+ }
+
+ private static string[] SplitPair(string subject)
+ {
+ var values = subject.Split(',', StringSplitOptions.TrimEntries);
+ if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace))
+ {
+ throw InvalidSelection();
+ }
+
+ return values;
+ }
+
+ private static string Required(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw InvalidSelection();
+ }
+
+ return value.Trim();
+ }
+
+ private static LegacySceneDataException InvalidSelection() =>
+ new("The comparison or yield legacy selection is unsupported.");
+
+ private static LegacySceneDataException InvalidResult() =>
+ new("A comparison stock-master lookup returned an invalid result.");
+
+ private sealed record StockMasterLookup(string TableName, DataQuerySpec Spec);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs
new file mode 100644
index 0000000..1e141f0
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs
@@ -0,0 +1,1159 @@
+#nullable enable
+
+using System.Globalization;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public enum ComparisonSeriesTarget
+{
+ First,
+ Second
+}
+
+public enum ComparisonEquityMarket
+{
+ Kospi,
+ Kosdaq
+}
+
+public enum ComparisonChangeDirection
+{
+ LimitUp,
+ Up,
+ Flat,
+ Down,
+ LimitDown
+}
+
+public enum ComparisonGraphPeriod
+{
+ Daily,
+ FiveDays,
+ OneMonth,
+ ThreeMonths,
+ SixMonths,
+ TwelveMonths
+}
+
+public sealed record ComparisonQuoteData(
+ ComparisonSeriesTarget Series,
+ ComparisonEquityMarket Market,
+ string StockName,
+ long CurrentPrice,
+ long NetChange,
+ double Rate,
+ ComparisonChangeDirection Direction);
+
+public sealed record ComparisonCandlePoint(
+ DateOnly OpenDay,
+ double High,
+ double Low,
+ double Open,
+ double Close);
+
+public sealed record ComparisonCandleSeriesData(
+ ComparisonQuoteData Quote,
+ IReadOnlyList Candles);
+
+public sealed record S5026SceneData(
+ ComparisonGraphPeriod Period,
+ IReadOnlyList Series) : ILegacySceneData;
+
+public sealed class S5026SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5026";
+
+ public IReadOnlyList Build(S5026SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ if (data.Period != ComparisonGraphPeriod.FiveDays)
+ {
+ throw new LegacySceneDataException("s5026 requires the legacy five-day period.");
+ }
+
+ var series = ComparisonSceneGuard.CandleSeries(data.Series);
+ var mutations = new List(80);
+ ComparisonSceneMutations.AppendQuoteHeader(
+ mutations,
+ series[0].Quote,
+ series[1].Quote);
+ ComparisonSceneMutations.AppendInitialDirectionHides(
+ mutations,
+ ComparisonInitialHideStyle.S5026);
+ ComparisonSceneMutations.AppendDirection(
+ mutations,
+ series[0].Quote.Series,
+ series[0].Quote.Direction);
+ ComparisonSceneMutations.AppendDirection(
+ mutations,
+ series[1].Quote.Series,
+ series[1].Quote.Direction);
+
+ foreach (var item in series)
+ {
+ AppendCandles(mutations, item);
+ }
+
+ return mutations;
+ }
+
+ private static void AppendCandles(
+ ICollection mutations,
+ ComparisonCandleSeriesData series)
+ {
+ var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series);
+ var candles = series.Candles;
+ var maximum = candles.Max(item => item.High);
+ var minimum = candles.Min(item => item.Low);
+ var range = maximum - minimum;
+ if (!double.IsFinite(range))
+ {
+ throw new LegacySceneDataException("Candle range must be finite.");
+ }
+
+ // The source divides every crop by range and emits NaN when all candles are equal.
+ // RangePercent pins that degenerate chart to zero-percent crop coordinates.
+ for (var index = 0; index < candles.Count; index++)
+ {
+ var candleNumber = index + 1;
+ ComparisonSceneMutations.AppendCandleDate(
+ mutations,
+ seriesNumber,
+ candleNumber,
+ candles.Count,
+ candles[index].OpenDay);
+
+ var candle = candles[index];
+ var high = ComparisonGraphSafety.RangePercent(candle.High, maximum, range);
+ var low = ComparisonGraphSafety.RangePercent(candle.Low, maximum, range);
+ var open = ComparisonGraphSafety.RangePercent(candle.Open, maximum, range);
+ var close = ComparisonGraphSafety.RangePercent(candle.Close, maximum, range);
+ var bar = $"candlebar{seriesNumber}_{candleNumber}";
+ var body = $"candlebody{seriesNumber}_{candleNumber}";
+ mutations.Add(new PlayoutSetCropKey(
+ bar,
+ 1,
+ 0,
+ high,
+ 0,
+ low,
+ PlayoutCropEdges.Vertical));
+
+ (float Top, float Bottom, int R, int G, int B) bodyStyle;
+ if (candle.Open > candle.Close)
+ {
+ bodyStyle = (open, close, 15, 99, 189);
+ }
+ else if (candle.Open < candle.Close)
+ {
+ bodyStyle = (close, open, 170, 0, 0);
+ }
+ else
+ {
+ bodyStyle = (close - 0.1f, open + 3f, 95, 95, 95);
+ }
+
+ mutations.Add(new PlayoutSetCropKey(
+ body,
+ 1,
+ 0,
+ bodyStyle.Top,
+ 0,
+ bodyStyle.Bottom,
+ PlayoutCropEdges.Vertical));
+ mutations.Add(new PlayoutSetFaceColor(
+ body,
+ bodyStyle.R,
+ bodyStyle.G,
+ bodyStyle.B));
+ mutations.Add(new PlayoutSetFaceColor(
+ bar,
+ bodyStyle.R,
+ bodyStyle.G,
+ bodyStyle.B));
+ }
+ }
+}
+
+public sealed record ComparisonYieldPoint(DateOnly DataDay, int Value);
+
+public sealed record ComparisonYieldSeriesData(
+ ComparisonQuoteData Quote,
+ IReadOnlyList Points);
+
+public sealed record S5029SceneData(
+ ComparisonGraphPeriod Period,
+ IReadOnlyList Series) : ILegacySceneData;
+
+public sealed class S5029SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5029";
+
+ public IReadOnlyList Build(S5029SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ ComparisonSceneGuard.ValidatePeriod(data.Period);
+ return BuildComparisonYieldScene(
+ data.Series,
+ ComparisonInitialHideStyle.S5029,
+ maximumX: 812f,
+ baselineOffset: -50f);
+ }
+
+ internal static IReadOnlyList BuildComparisonYieldScene(
+ IReadOnlyList source,
+ ComparisonInitialHideStyle initialHideStyle,
+ float maximumX,
+ float baselineOffset)
+ {
+ var series = ComparisonSceneGuard.YieldSeries(source);
+ var mutations = new List();
+ ComparisonSceneMutations.AppendQuoteHeader(
+ mutations,
+ series[0].Quote,
+ series[1].Quote);
+ ComparisonSceneMutations.AppendInitialDirectionHides(mutations, initialHideStyle);
+ ComparisonSceneMutations.AppendDirection(
+ mutations,
+ series[0].Quote.Series,
+ series[0].Quote.Direction);
+ ComparisonSceneMutations.AppendDirection(
+ mutations,
+ series[1].Quote.Series,
+ series[1].Quote.Direction);
+
+ foreach (var item in series)
+ {
+ AppendYieldSeries(mutations, item, maximumX, baselineOffset);
+ }
+
+ return mutations;
+ }
+
+ private static void AppendYieldSeries(
+ ICollection mutations,
+ ComparisonYieldSeriesData series,
+ float maximumX,
+ float baselineOffset)
+ {
+ var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series);
+ var points = series.Points;
+ for (var index = 0; index < points.Count; index++)
+ {
+ ComparisonSceneMutations.AppendYieldDate(
+ mutations,
+ seriesNumber.ToString(CultureInfo.InvariantCulture),
+ index,
+ points.Count,
+ points[index].DataDay);
+ }
+
+ var maximum = points.Max(item => item.Value);
+ var minimum = points.Min(item => item.Value);
+ var range = (double)maximum - minimum;
+ var reference = points[^1].Value;
+ // The source divides by max-min. Equal values retain its configured baseline offset.
+ var baseline = range == 0d
+ ? baselineOffset
+ : ComparisonGraphSafety.Float(
+ ((reference - (double)minimum) / range * 150d) + baselineOffset,
+ "Comparison baseline");
+ mutations.Add(new PlayoutSetPosition(
+ "baseline" + seriesNumber,
+ 0,
+ baseline,
+ 0,
+ PlayoutVectorComponents.Y));
+
+ var reversed = points.Select(item => item.Value).Reverse().ToArray();
+ // The original divides by count-1. A single point is pinned at X=0 instead of NaN.
+ var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d);
+ const double tagStart = -383.48d;
+ const double tagEnd = 359.28d;
+ var tagStep = reversed.Length == 1
+ ? 0d
+ : (tagEnd - tagStart) / (reversed.Length - 1d);
+ var plus = new List();
+ var minus = new List();
+ var previous = 0d;
+
+ for (var index = 0; index < reversed.Length; index++)
+ {
+ var x = graphStep * index;
+ var value = range == 0d
+ ? 0d
+ : (reference - (double)reversed[index]) / range * 150d;
+ ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous);
+ previous = value;
+
+ var tagX = ComparisonGraphSafety.Float(
+ tagStart + (index * tagStep),
+ "Comparison tag X");
+ if (reversed[index] == maximum)
+ {
+ mutations.Add(new PlayoutSetPosition(
+ "highTag" + seriesNumber,
+ tagX,
+ 0,
+ 0,
+ PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "highPrice" + seriesNumber,
+ reversed[index].ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+
+ if (reversed[index] == minimum)
+ {
+ mutations.Add(new PlayoutSetPosition(
+ "lowTag" + seriesNumber,
+ tagX,
+ 0,
+ 0,
+ PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "lowPrice" + seriesNumber,
+ reversed[index].ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+ }
+
+ ComparisonSceneMutations.AppendSignedGraphs(
+ mutations,
+ seriesNumber.ToString(CultureInfo.InvariantCulture),
+ 0,
+ baseline,
+ PlayoutVectorComponents.Y,
+ maximumX,
+ plus,
+ minus);
+ }
+}
+
+public sealed record S5087SceneData(
+ ComparisonGraphPeriod Period,
+ IReadOnlyList Series) : ILegacySceneData;
+
+public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5087";
+
+ public IReadOnlyList Build(S5087SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ if (data.Period != ComparisonGraphPeriod.FiveDays)
+ {
+ throw new LegacySceneDataException("s5087 requires the legacy five-day period.");
+ }
+
+ return S5029SceneMutationBuilder.BuildComparisonYieldScene(
+ data.Series,
+ ComparisonInitialHideStyle.S5087,
+ maximumX: 700f,
+ baselineOffset: -170f);
+ }
+}
+
+public enum YieldGraphPeriod
+{
+ FiveDays,
+ TwentyDays,
+ SixtyDays,
+ OneHundredTwentyDays,
+ TwoHundredFortyDays
+}
+
+public enum YieldGraphInstrumentKind
+{
+ WonDollar,
+ WonYen,
+ WonYuan,
+ WonEuro,
+ KospiIndex,
+ KosdaqIndex,
+ Kospi200Index,
+ Krx100Index,
+ KospiIndustry,
+ KosdaqIndustry,
+ KospiStock,
+ KosdaqStock
+}
+
+public sealed record YieldGraphInstrument(
+ YieldGraphInstrumentKind Kind,
+ string? Name = null);
+
+///
+/// A null value represents a missing DB cell. s5086 explicitly normalizes a later missing
+/// value while s50860 rejects it, matching their distinct legacy data paths.
+///
+public sealed record YieldGraphPoint(DateOnly DataDay, double? Value);
+
+public sealed record S5086SceneData(
+ YieldGraphPeriod Period,
+ YieldGraphInstrument Instrument,
+ IReadOnlyList Points) : ILegacySceneData;
+
+public sealed class S5086SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5086";
+
+ public IReadOnlyList Build(S5086SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var points = SingleYieldSceneGuard.NormalizeS5086Points(data.Points);
+ var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: false);
+ var mutations = new List();
+ SingleYieldSceneMutations.AppendHeader(
+ mutations,
+ SingleYieldSceneGuard.Subtitle(data.Period),
+ presentation);
+ SingleYieldSceneMutations.AppendDates(mutations, points);
+
+ var price = points[0].Value;
+ var reference = points[^1].Value;
+ var rate = reference == 0d
+ ? 0d
+ : (price - reference) / reference * 100d;
+ if (!double.IsFinite(rate))
+ {
+ throw new LegacySceneDataException("s5086 rate must be finite.");
+ }
+ mutations.Add(new PlayoutSetValue(
+ "price",
+ SingleYieldSceneGuard.Format(price, presentation.Format)));
+ mutations.Add(new PlayoutSetValue(
+ "referencePrice",
+ SingleYieldSceneGuard.Format(reference, presentation.Format)));
+ mutations.Add(new PlayoutSetValue(
+ "rate",
+ rate.ToString("0.00", CultureInfo.InvariantCulture)));
+ if (rate > 0d)
+ {
+ mutations.Add(new PlayoutSetVisible("bg1", true));
+ mutations.Add(new PlayoutSetVisible("bg2", false));
+ mutations.Add(new PlayoutSetVisible("bg3", false));
+ }
+ else if (rate < 0d)
+ {
+ mutations.Add(new PlayoutSetVisible("bg1", false));
+ mutations.Add(new PlayoutSetVisible("bg2", false));
+ mutations.Add(new PlayoutSetVisible("bg3", true));
+ }
+ else
+ {
+ mutations.Add(new PlayoutSetVisible("bg1", false));
+ mutations.Add(new PlayoutSetVisible("bg2", true));
+ mutations.Add(new PlayoutSetVisible("bg3", false));
+ }
+
+ SingleYieldSceneMutations.AppendGraph(
+ mutations,
+ points,
+ presentation.Format);
+ return mutations;
+ }
+}
+
+public enum YieldQuoteDirection
+{
+ Up,
+ Flat,
+ Down
+}
+
+public sealed record S50860SceneData(
+ YieldGraphPeriod Period,
+ YieldGraphInstrument Instrument,
+ double ChangePrice,
+ double Rate,
+ YieldQuoteDirection Direction,
+ IReadOnlyList Points) : ILegacySceneData;
+
+public sealed class S50860SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s50860";
+
+ public IReadOnlyList Build(S50860SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ if (!double.IsFinite(data.ChangePrice) || !double.IsFinite(data.Rate))
+ {
+ throw new LegacySceneDataException("s50860 quote values must be finite.");
+ }
+
+ var points = SingleYieldSceneGuard.RequireCompletePoints(data.Points, "s50860");
+ var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: true);
+ var mutations = new List();
+ SingleYieldSceneMutations.AppendHeader(
+ mutations,
+ SingleYieldSceneGuard.Subtitle(data.Period),
+ presentation);
+ SingleYieldSceneMutations.AppendDates(mutations, points);
+ mutations.Add(new PlayoutSetValue(
+ "price",
+ SingleYieldSceneGuard.Format(points[0].Value, presentation.Format)));
+ mutations.Add(new PlayoutSetValue(
+ "changePrice",
+ Math.Abs(data.ChangePrice).ToString("0.00", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "rate",
+ data.Rate.ToString("0.00", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetVisible("up", false));
+ mutations.Add(new PlayoutSetVisible("flat", false));
+ mutations.Add(new PlayoutSetVisible("down", false));
+
+ var (active, red, green, blue) = data.Direction switch
+ {
+ YieldQuoteDirection.Up => ("up", 170, 0, 0),
+ YieldQuoteDirection.Flat => ("flat", 95, 95, 95),
+ YieldQuoteDirection.Down => ("down", 15, 99, 189),
+ _ => throw new LegacySceneDataException("Unknown yield quote direction.")
+ };
+ mutations.Add(new PlayoutSetVisible(active, true));
+ mutations.Add(new PlayoutSetFaceColor("bg", red, green, blue));
+
+ SingleYieldSceneMutations.AppendGraph(
+ mutations,
+ points,
+ presentation.Format);
+ return mutations;
+ }
+}
+
+internal enum ComparisonInitialHideStyle
+{
+ S5026,
+ S5029,
+ S5087
+}
+
+internal static class ComparisonSceneGuard
+{
+ public static IReadOnlyList CandleSeries(
+ IReadOnlyList? source)
+ {
+ var input = LegacySceneBuilderGuard.Count(source, 2, "Series");
+ var ordered = new ComparisonCandleSeriesData?[2];
+ foreach (var item in input)
+ {
+ var series = LegacySceneBuilderGuard.NotNull(item, "Series");
+ ValidateQuote(series.Quote);
+ var index = SeriesNumber(series.Quote.Series) - 1;
+ if (ordered[index] is not null)
+ {
+ throw new LegacySceneDataException("Comparison series targets must be unique.");
+ }
+
+ var candles = LegacySceneBuilderGuard.Count(series.Candles, 5, "Candles");
+ foreach (var candle in candles)
+ {
+ var point = LegacySceneBuilderGuard.NotNull(candle, "Candles");
+ if (!double.IsFinite(point.High) || !double.IsFinite(point.Low) ||
+ !double.IsFinite(point.Open) || !double.IsFinite(point.Close) ||
+ point.High < point.Low)
+ {
+ throw new LegacySceneDataException("Candle values are invalid.");
+ }
+ }
+
+ ordered[index] = series;
+ }
+
+ return ordered.Select(item => item!).ToArray();
+ }
+
+ public static IReadOnlyList YieldSeries(
+ IReadOnlyList? source)
+ {
+ var input = LegacySceneBuilderGuard.Count(source, 2, "Series");
+ var ordered = new ComparisonYieldSeriesData?[2];
+ foreach (var item in input)
+ {
+ var series = LegacySceneBuilderGuard.NotNull(item, "Series");
+ ValidateQuote(series.Quote);
+ var index = SeriesNumber(series.Quote.Series) - 1;
+ if (ordered[index] is not null)
+ {
+ throw new LegacySceneDataException("Comparison series targets must be unique.");
+ }
+
+ _ = LegacySceneBuilderGuard.Range(series.Points, 1, 10_000, "Points");
+ foreach (var point in series.Points)
+ {
+ _ = LegacySceneBuilderGuard.NotNull(point, "Points");
+ }
+
+ ordered[index] = series;
+ }
+
+ return ordered.Select(item => item!).ToArray();
+ }
+
+ public static void ValidatePeriod(ComparisonGraphPeriod period)
+ {
+ _ = period switch
+ {
+ ComparisonGraphPeriod.Daily or
+ ComparisonGraphPeriod.FiveDays or
+ ComparisonGraphPeriod.OneMonth or
+ ComparisonGraphPeriod.ThreeMonths or
+ ComparisonGraphPeriod.SixMonths or
+ ComparisonGraphPeriod.TwelveMonths => true,
+ _ => throw new LegacySceneDataException("Unknown comparison graph period.")
+ };
+ }
+
+ public static int SeriesNumber(ComparisonSeriesTarget series) => series switch
+ {
+ ComparisonSeriesTarget.First => 1,
+ ComparisonSeriesTarget.Second => 2,
+ _ => throw new LegacySceneDataException("Unknown comparison series target.")
+ };
+
+ private static void ValidateQuote(ComparisonQuoteData? value)
+ {
+ var quote = LegacySceneBuilderGuard.NotNull(value, "Quote");
+ _ = SeriesNumber(quote.Series);
+ _ = quote.Market switch
+ {
+ ComparisonEquityMarket.Kospi or ComparisonEquityMarket.Kosdaq => true,
+ _ => throw new LegacySceneDataException("Unknown comparison equity market.")
+ };
+ _ = quote.Direction switch
+ {
+ ComparisonChangeDirection.LimitUp or
+ ComparisonChangeDirection.Up or
+ ComparisonChangeDirection.Flat or
+ ComparisonChangeDirection.Down or
+ ComparisonChangeDirection.LimitDown => true,
+ _ => throw new LegacySceneDataException("Unknown comparison change direction.")
+ };
+ LegacySceneBuilderGuard.Text(quote.StockName, "StockName");
+ if (!double.IsFinite(quote.Rate))
+ {
+ throw new LegacySceneDataException("Comparison quote rate must be finite.");
+ }
+ }
+}
+
+internal static class ComparisonSceneMutations
+{
+ public static void AppendQuoteHeader(
+ ICollection mutations,
+ ComparisonQuoteData first,
+ ComparisonQuoteData second)
+ {
+ mutations.Add(new PlayoutSetValue("title1", first.StockName));
+ mutations.Add(new PlayoutSetValue("title2", second.StockName));
+ mutations.Add(new PlayoutSetValue(
+ "price1",
+ first.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "price2",
+ second.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "changePrice1",
+ first.NetChange.ToString("#,##0", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "changePrice2",
+ second.NetChange.ToString("#,##0", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "rate1",
+ first.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "rate2",
+ second.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
+ }
+
+ public static void AppendInitialDirectionHides(
+ ICollection mutations,
+ ComparisonInitialHideStyle style)
+ {
+ string[][] orders = style switch
+ {
+ ComparisonInitialHideStyle.S5026 =>
+ [
+ ["up", "flat", "down"],
+ ["up", "flat", "down"]
+ ],
+ ComparisonInitialHideStyle.S5029 =>
+ [
+ ["upup", "up", "flat", "downdown", "down"],
+ ["upup", "up", "flat", "downdown", "down"]
+ ],
+ ComparisonInitialHideStyle.S5087 =>
+ [
+ ["up", "upup", "flat", "down", "downdown"],
+ ["upup", "up", "flat", "down", "downdown"]
+ ],
+ _ => throw new LegacySceneDataException("Unknown comparison visibility style.")
+ };
+
+ for (var series = 1; series <= 2; series++)
+ {
+ foreach (var objectName in orders[series - 1])
+ {
+ mutations.Add(new PlayoutSetVisible(objectName + series, false));
+ }
+ }
+ }
+
+ public static void AppendDirection(
+ ICollection mutations,
+ ComparisonSeriesTarget target,
+ ComparisonChangeDirection direction)
+ {
+ var suffix = ComparisonSceneGuard.SeriesNumber(target)
+ .ToString(CultureInfo.InvariantCulture);
+ foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" })
+ {
+ mutations.Add(new PlayoutSetVisible(name + suffix, false));
+ }
+
+ var (active, selectedBackground) = direction switch
+ {
+ ComparisonChangeDirection.LimitUp => ("upup", 1),
+ ComparisonChangeDirection.Up => ("up", 1),
+ ComparisonChangeDirection.Flat => ("flat", 2),
+ ComparisonChangeDirection.Down => ("down", 3),
+ ComparisonChangeDirection.LimitDown => ("downdown", 3),
+ _ => throw new LegacySceneDataException("Unknown comparison change direction.")
+ };
+ mutations.Add(new PlayoutSetVisible(active + suffix, true));
+ for (var variant = 1; variant <= 3; variant++)
+ {
+ mutations.Add(new PlayoutSetVisible(
+ $"bg{suffix}_{variant}",
+ variant == selectedBackground));
+ }
+ }
+
+ public static void AppendCandleDate(
+ ICollection mutations,
+ int seriesNumber,
+ int candleNumber,
+ int candleCount,
+ DateOnly date)
+ {
+ int? slot = candleNumber switch
+ {
+ 1 => 1,
+ _ when candleNumber == candleCount => 5,
+ _ when candleNumber == (int)Math.Ceiling(candleCount / 2d) => 3,
+ _ => null
+ };
+ if (slot.HasValue)
+ {
+ mutations.Add(new PlayoutSetValue(
+ $"date{seriesNumber}_{slot.Value}",
+ FormatDate(date)));
+ }
+ }
+
+ public static void AppendYieldDate(
+ ICollection mutations,
+ string seriesSuffix,
+ int index,
+ int count,
+ DateOnly date)
+ {
+ var last = count - 1;
+ int? slot = index switch
+ {
+ 0 => 5,
+ _ when index == last => 1,
+ _ when index == (int)Math.Ceiling(last / 2d) => 3,
+ _ when index == (int)Math.Ceiling(last / 4d) => 4,
+ _ when index == (int)Math.Ceiling(last * 3d / 4d) => 2,
+ _ => null
+ };
+ if (slot.HasValue)
+ {
+ var objectName = string.IsNullOrEmpty(seriesSuffix)
+ ? "date" + slot.Value
+ : "date" + seriesSuffix + "_" + slot.Value;
+ mutations.Add(new PlayoutSetValue(
+ objectName,
+ FormatDate(date)));
+ }
+ }
+
+ public static void AppendSignedGraphs(
+ ICollection mutations,
+ string suffix,
+ float positionX,
+ float baseline,
+ PlayoutVectorComponents positionComponents,
+ float maximumX,
+ IReadOnlyList plus,
+ IReadOnlyList minus)
+ {
+ mutations.Add(new PlayoutSetPosition(
+ "minusGraph" + suffix,
+ positionX,
+ baseline,
+ 0,
+ positionComponents));
+ mutations.Add(new PlayoutSetPathShapePoints(
+ "minusGraph" + suffix,
+ SignedShapePoints(minus, maximumX, invertY: true)));
+ mutations.Add(new PlayoutSetPosition(
+ "plusGraph" + suffix,
+ positionX,
+ baseline,
+ 0,
+ positionComponents));
+ mutations.Add(new PlayoutSetPathShapePoints(
+ "plusGraph" + suffix,
+ SignedShapePoints(plus, maximumX, invertY: false)));
+ }
+
+ private static IReadOnlyList SignedShapePoints(
+ IReadOnlyList source,
+ float maximumX,
+ bool invertY)
+ {
+ var points = new List(source.Count + 2) { new(0, 0, 0) };
+ points.AddRange(source.Select(point => new PlayoutPoint(
+ point.X,
+ invertY ? -point.Y : point.Y,
+ 0)));
+ points.Add(new PlayoutPoint(maximumX, 0, 0));
+ return points;
+ }
+
+ private static string FormatDate(DateOnly date) =>
+ date.ToString("MM/dd", CultureInfo.InvariantCulture);
+}
+
+internal enum YieldValueFormat
+{
+ Integer,
+ DecimalTwo
+}
+
+internal enum YieldHeaderOrder
+{
+ UnitThenTitle,
+ UnitTitleUnit,
+ TitleThenUnit
+}
+
+internal sealed record YieldInstrumentPresentation(
+ string Title,
+ string Unit,
+ YieldValueFormat Format,
+ YieldHeaderOrder HeaderOrder);
+
+internal sealed record NormalizedYieldPoint(DateOnly DataDay, double Value);
+
+internal static class SingleYieldSceneGuard
+{
+ public static string Subtitle(YieldGraphPeriod period) => period switch
+ {
+ YieldGraphPeriod.FiveDays => "1주일",
+ YieldGraphPeriod.TwentyDays => "1개월",
+ YieldGraphPeriod.SixtyDays => "3개월",
+ YieldGraphPeriod.OneHundredTwentyDays => "6개월",
+ YieldGraphPeriod.TwoHundredFortyDays => "12개월",
+ _ => throw new LegacySceneDataException("Unknown yield graph period.")
+ };
+
+ public static YieldInstrumentPresentation Presentation(
+ YieldGraphInstrument? source,
+ bool slashFxTitle)
+ {
+ var instrument = LegacySceneBuilderGuard.NotNull(source, "Instrument");
+ return instrument.Kind switch
+ {
+ YieldGraphInstrumentKind.WonDollar => Fx("원달러", "원/달러"),
+ YieldGraphInstrumentKind.WonYen => Fx("원엔", "원/엔"),
+ YieldGraphInstrumentKind.WonYuan => Fx("원위엔", "원/위엔"),
+ YieldGraphInstrumentKind.WonEuro => Fx("원유로", "원/유로"),
+ YieldGraphInstrumentKind.KospiIndex => Index("코스피 수익률"),
+ YieldGraphInstrumentKind.KosdaqIndex => Index("코스닥 수익률"),
+ YieldGraphInstrumentKind.Kospi200Index => Index("코스피200 수익률"),
+ YieldGraphInstrumentKind.Krx100Index => Index("KRX100 수익률"),
+ YieldGraphInstrumentKind.KospiIndustry => Industry("코스피 - "),
+ YieldGraphInstrumentKind.KosdaqIndustry => Industry("코스닥 - "),
+ YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock => Stock(),
+ _ => throw new LegacySceneDataException("Unknown yield graph instrument.")
+ };
+
+ YieldInstrumentPresentation Fx(string yieldTitle, string slashTitle) => new(
+ slashFxTitle ? slashTitle : yieldTitle + " 수익률",
+ "단위 : 원",
+ YieldValueFormat.DecimalTwo,
+ YieldHeaderOrder.UnitThenTitle);
+
+ static YieldInstrumentPresentation Index(string title) => new(
+ title,
+ "단위 : p",
+ YieldValueFormat.DecimalTwo,
+ YieldHeaderOrder.UnitTitleUnit);
+
+ YieldInstrumentPresentation Industry(string prefix) => new(
+ prefix + LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"),
+ "단위 : p",
+ YieldValueFormat.DecimalTwo,
+ YieldHeaderOrder.TitleThenUnit);
+
+ YieldInstrumentPresentation Stock() => new(
+ LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"),
+ "단위 : 원",
+ YieldValueFormat.Integer,
+ YieldHeaderOrder.UnitThenTitle);
+ }
+
+ public static IReadOnlyList NormalizeS5086Points(
+ IReadOnlyList? source)
+ {
+ var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points");
+ var normalized = new NormalizedYieldPoint[points.Count];
+ double? previous = null;
+ for (var index = 0; index < points.Count; index++)
+ {
+ var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]");
+ if (point.Value is null)
+ {
+ // The source attempted rows[i-1] and crashes when i==0. Fail closed there;
+ // later missing rows are explicitly forward-filled, which is the source comment's intent.
+ if (previous is null)
+ {
+ throw new LegacySceneDataException("The first s5086 graph value is required.");
+ }
+
+ normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value);
+ continue;
+ }
+
+ if (!double.IsFinite(point.Value.Value))
+ {
+ throw new LegacySceneDataException("Yield graph values must be finite.");
+ }
+
+ previous = point.Value.Value;
+ normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value);
+ }
+
+ return normalized;
+ }
+
+ public static IReadOnlyList RequireCompletePoints(
+ IReadOnlyList? source,
+ string scene)
+ {
+ var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points");
+ var normalized = new NormalizedYieldPoint[points.Count];
+ for (var index = 0; index < points.Count; index++)
+ {
+ var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]");
+ if (point.Value is null || !double.IsFinite(point.Value.Value))
+ {
+ throw new LegacySceneDataException(scene + " requires every graph value.");
+ }
+
+ normalized[index] = new NormalizedYieldPoint(point.DataDay, point.Value.Value);
+ }
+
+ return normalized;
+ }
+
+ public static string Format(double value, YieldValueFormat format) => format switch
+ {
+ YieldValueFormat.Integer => value.ToString("#,##0", CultureInfo.InvariantCulture),
+ YieldValueFormat.DecimalTwo => value.ToString("#,##0.00", CultureInfo.InvariantCulture),
+ _ => throw new LegacySceneDataException("Unknown yield value format.")
+ };
+}
+
+internal static class SingleYieldSceneMutations
+{
+ public static void AppendHeader(
+ ICollection mutations,
+ string subtitle,
+ YieldInstrumentPresentation presentation)
+ {
+ mutations.Add(new PlayoutSetValue("subtitle", subtitle));
+ switch (presentation.HeaderOrder)
+ {
+ case YieldHeaderOrder.UnitThenTitle:
+ mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
+ mutations.Add(new PlayoutSetValue("title", presentation.Title));
+ break;
+ case YieldHeaderOrder.UnitTitleUnit:
+ mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
+ mutations.Add(new PlayoutSetValue("title", presentation.Title));
+ mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
+ break;
+ case YieldHeaderOrder.TitleThenUnit:
+ mutations.Add(new PlayoutSetValue("title", presentation.Title));
+ mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
+ break;
+ default:
+ throw new LegacySceneDataException("Unknown yield header order.");
+ }
+ }
+
+ public static void AppendDates(
+ ICollection mutations,
+ IReadOnlyList points)
+ {
+ for (var index = 0; index < points.Count; index++)
+ {
+ ComparisonSceneMutations.AppendYieldDate(
+ mutations,
+ string.Empty,
+ index,
+ points.Count,
+ points[index].DataDay);
+ }
+ }
+
+ public static void AppendGraph(
+ ICollection mutations,
+ IReadOnlyList points,
+ YieldValueFormat format)
+ {
+ var maximum = points.Max(item => item.Value);
+ var minimum = points.Min(item => item.Value);
+ var range = maximum - minimum;
+ if (!double.IsFinite(range))
+ {
+ throw new LegacySceneDataException("Yield graph range must be finite.");
+ }
+
+ var reference = points[^1].Value;
+ // The source divides by max-min. Equal values retain the fixed -75 baseline.
+ var baseline = range == 0d
+ ? -75f
+ : ComparisonGraphSafety.Float(
+ ((reference - minimum) / range * 225d) - 75d,
+ "Yield baseline");
+ mutations.Add(new PlayoutSetPosition(
+ "baseline",
+ 0,
+ baseline,
+ 0,
+ PlayoutVectorComponents.Y));
+
+ var reversed = points.Select(item => item.Value).Reverse().ToArray();
+ const float maximumX = 1333f;
+ // The source divides by count-1. A single point is pinned at X=0 instead of NaN.
+ var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d);
+ const double tagStart = -647.50d;
+ const double tagEnd = 653.79d;
+ var tagStep = reversed.Length == 1
+ ? 0d
+ : (tagEnd - tagStart) / (reversed.Length - 1d);
+ var plus = new List();
+ var minus = new List();
+ var previous = 0d;
+ for (var index = 0; index < reversed.Length; index++)
+ {
+ var x = graphStep * index;
+ var value = range == 0d
+ ? 0d
+ : (reference - reversed[index]) / range * 225d;
+ ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous);
+ previous = value;
+ var tagX = ComparisonGraphSafety.Float(
+ tagStart + (index * tagStep),
+ "Yield tag X");
+
+ if (reversed[index] == maximum)
+ {
+ mutations.Add(new PlayoutSetPosition(
+ "highTag",
+ tagX,
+ 0,
+ 0,
+ PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "highPrice",
+ SingleYieldSceneGuard.Format(reversed[index], format)));
+ }
+
+ if (reversed[index] == minimum)
+ {
+ mutations.Add(new PlayoutSetPosition(
+ "lowTag",
+ tagX,
+ 0,
+ 0,
+ PlayoutVectorComponents.X));
+ mutations.Add(new PlayoutSetValue(
+ "lowPrice",
+ SingleYieldSceneGuard.Format(reversed[index], format)));
+ }
+ }
+
+ ComparisonSceneMutations.AppendSignedGraphs(
+ mutations,
+ string.Empty,
+ -659.82f,
+ baseline,
+ PlayoutVectorComponents.XYZ,
+ maximumX,
+ plus,
+ minus);
+ }
+}
+
+internal static class ComparisonGraphSafety
+{
+ public static float RangePercent(double value, double maximum, double range)
+ {
+ var result = range == 0d
+ ? 0d
+ : Math.Abs(100d * (value - maximum) / range);
+ return Float(result, "Candle crop");
+ }
+
+ public static void AppendSignedPoint(
+ ICollection plus,
+ ICollection minus,
+ double x,
+ double value,
+ double step,
+ double previous)
+ {
+ if ((previous > 0d && value < 0d) || (previous < 0d && value > 0d))
+ {
+ var denominator = Math.Abs(previous - value);
+ var crossingX = denominator == 0d
+ ? x
+ : x - (step * (Math.Abs(value) / denominator));
+ var crossing = new PlayoutPoint(Float(crossingX, "Graph crossing X"), 0, 0);
+ plus.Add(crossing);
+ minus.Add(crossing);
+ }
+
+ var point = new PlayoutPoint(
+ Float(x, "Graph X"),
+ Float(value, "Graph Y"),
+ 0);
+ if (value < 0d)
+ {
+ plus.Add(point);
+ }
+ else if (value > 0d)
+ {
+ minus.Add(point);
+ }
+ else
+ {
+ plus.Add(point);
+ minus.Add(point);
+ }
+ }
+
+ public static float Float(double value, string name)
+ {
+ if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue)
+ {
+ throw new LegacySceneDataException(name + " must be a finite float.");
+ }
+
+ return (float)value;
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs
new file mode 100644
index 0000000..4f72333
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneDataLoaders.cs
@@ -0,0 +1,1102 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record ComparisonStockSelection(
+ ComparisonEquityMarket Market,
+ string StockName);
+
+public sealed record ComparisonPairSceneLoadRequest(
+ ComparisonGraphPeriod Period,
+ ComparisonStockSelection First,
+ ComparisonStockSelection Second);
+
+public sealed record YieldSceneLoadRequest(
+ YieldGraphPeriod Period,
+ YieldGraphInstrument Instrument);
+
+public sealed class S5026SceneDataLoader
+{
+ private readonly ComparisonAndYieldSceneLoaderCore _core;
+
+ public S5026SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _core = new ComparisonAndYieldSceneLoaderCore(executor);
+ }
+
+ public async Task LoadAsync(
+ ComparisonPairSceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ if (request.Period != ComparisonGraphPeriod.FiveDays)
+ {
+ throw ComparisonAndYieldLoadGuard.InvalidRequest();
+ }
+
+ var series = await _core.LoadCandlePairAsync(
+ request,
+ "SCENE_5026",
+ cancellationToken).ConfigureAwait(false);
+ return new S5026SceneData(request.Period, series);
+ }
+}
+
+public sealed class S5029SceneDataLoader
+{
+ private readonly ComparisonAndYieldSceneLoaderCore _core;
+
+ public S5029SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _core = new ComparisonAndYieldSceneLoaderCore(executor);
+ }
+
+ public async Task LoadAsync(
+ ComparisonPairSceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ _ = ComparisonAndYieldLoadGuard.ComparisonRowLimit(request.Period);
+ var series = await _core.LoadComparisonYieldPairAsync(
+ request,
+ "SCENE_5029",
+ cancellationToken).ConfigureAwait(false);
+ return new S5029SceneData(request.Period, series);
+ }
+}
+
+public sealed class S5087SceneDataLoader
+{
+ private readonly ComparisonAndYieldSceneLoaderCore _core;
+
+ public S5087SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _core = new ComparisonAndYieldSceneLoaderCore(executor);
+ }
+
+ public async Task LoadAsync(
+ ComparisonPairSceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ if (request.Period != ComparisonGraphPeriod.FiveDays)
+ {
+ throw ComparisonAndYieldLoadGuard.InvalidRequest();
+ }
+
+ var series = await _core.LoadComparisonYieldPairAsync(
+ request,
+ "SCENE_5087",
+ cancellationToken).ConfigureAwait(false);
+ return new S5087SceneData(request.Period, series);
+ }
+}
+
+public sealed class S5086SceneDataLoader
+{
+ private readonly ComparisonAndYieldSceneLoaderCore _core;
+
+ public S5086SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _core = new ComparisonAndYieldSceneLoaderCore(executor);
+ }
+
+ public async Task LoadAsync(
+ YieldSceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var points = await _core.LoadSingleYieldAsync(
+ request,
+ "SCENE_5086_YIELD",
+ allowMissingValues: true,
+ cancellationToken).ConfigureAwait(false);
+ return new S5086SceneData(request.Period, request.Instrument, points);
+ }
+}
+
+public sealed class S50860SceneDataLoader
+{
+ private readonly ComparisonAndYieldSceneLoaderCore _core;
+
+ public S50860SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _core = new ComparisonAndYieldSceneLoaderCore(executor);
+ }
+
+ public async Task LoadAsync(
+ YieldSceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ComparisonAndYieldLoadGuard.ValidateInstrument(request.Instrument);
+ if (!ComparisonAndYieldLoadGuard.IsFx(request.Instrument.Kind))
+ {
+ // The source reads cells 4/5/6 for every branch. Only Exchange supplies
+ // net_chg/chg_type/rate at those positions; index, industry, and stock
+ // result shapes cannot produce a valid s50860 quote and are rejected.
+ throw ComparisonAndYieldLoadGuard.InvalidRequest();
+ }
+
+ var loaded = await _core.LoadQuotedFxYieldAsync(
+ request,
+ "SCENE_50860_YIELD",
+ cancellationToken).ConfigureAwait(false);
+ return new S50860SceneData(
+ request.Period,
+ request.Instrument,
+ loaded.ChangePrice,
+ loaded.Rate,
+ loaded.Direction,
+ loaded.Points);
+ }
+}
+
+internal sealed class ComparisonAndYieldSceneLoaderCore
+{
+ private static readonly string[] CandleColumns =
+ [
+ "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
+ "OPEN_DAY", "HIGH_VALUE", "LOW_VALUE", "OPEN_VALUE", "CLOSE_VALUE"
+ ];
+
+ private static readonly string[] ComparisonYieldColumns =
+ [
+ "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
+ "DATA_DAY", "VALUE"
+ ];
+
+ private static readonly string[] SingleYieldColumns = ["DATA_DAY", "VALUE"];
+
+ private static readonly string[] QuotedFxColumns =
+ ["DATA_DAY", "VALUE", "CHANGE_PRICE", "RATE", "DIRECTION"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public ComparisonAndYieldSceneLoaderCore(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task> LoadCandlePairAsync(
+ ComparisonPairSceneLoadRequest request,
+ string tablePrefix,
+ CancellationToken cancellationToken)
+ {
+ var selections = ComparisonAndYieldLoadGuard.Pair(request);
+ var result = new ComparisonCandleSeriesData[2];
+ for (var index = 0; index < selections.Length; index++)
+ {
+ var target = index == 0
+ ? ComparisonSeriesTarget.First
+ : ComparisonSeriesTarget.Second;
+ var selection = selections[index];
+ var spec = ComparisonAndYieldQueryFactory.ComparisonCandle(selection);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ tablePrefix + "_SERIES_" + (index + 1).ToString(CultureInfo.InvariantCulture),
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ result[index] = MapCandleSeries(table, target, selection.Market);
+ }
+
+ return result;
+ }
+
+ public async Task> LoadComparisonYieldPairAsync(
+ ComparisonPairSceneLoadRequest request,
+ string tablePrefix,
+ CancellationToken cancellationToken)
+ {
+ var selections = ComparisonAndYieldLoadGuard.Pair(request);
+ var rowLimit = ComparisonAndYieldLoadGuard.ComparisonRowLimit(request.Period);
+ var result = new ComparisonYieldSeriesData[2];
+ for (var index = 0; index < selections.Length; index++)
+ {
+ var target = index == 0
+ ? ComparisonSeriesTarget.First
+ : ComparisonSeriesTarget.Second;
+ var selection = selections[index];
+ var spec = ComparisonAndYieldQueryFactory.ComparisonYield(selection, rowLimit);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ tablePrefix + "_SERIES_" + (index + 1).ToString(CultureInfo.InvariantCulture),
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ result[index] = MapComparisonYieldSeries(
+ table,
+ target,
+ selection.Market,
+ rowLimit);
+ }
+
+ return result;
+ }
+
+ public async Task> LoadSingleYieldAsync(
+ YieldSceneLoadRequest request,
+ string tableName,
+ bool allowMissingValues,
+ CancellationToken cancellationToken)
+ {
+ var rowLimit = ComparisonAndYieldLoadGuard.YieldRowLimit(request.Period);
+ ComparisonAndYieldLoadGuard.ValidateInstrument(request.Instrument);
+ var spec = ComparisonAndYieldQueryFactory.SingleYield(
+ request.Instrument,
+ rowLimit);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ tableName,
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ return MapSingleYield(table, rowLimit, allowMissingValues);
+ }
+
+ public async Task LoadQuotedFxYieldAsync(
+ YieldSceneLoadRequest request,
+ string tableName,
+ CancellationToken cancellationToken)
+ {
+ var rowLimit = ComparisonAndYieldLoadGuard.YieldRowLimit(request.Period);
+ var spec = ComparisonAndYieldQueryFactory.QuotedFxYield(
+ request.Instrument,
+ rowLimit);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ tableName,
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ return MapQuotedFxYield(table, rowLimit);
+ }
+
+ private static ComparisonCandleSeriesData MapCandleSeries(
+ DataTable? table,
+ ComparisonSeriesTarget target,
+ ComparisonEquityMarket market)
+ {
+ ComparisonAndYieldTableReader.RequireShape(table, 5, CandleColumns);
+ var rows = table!.Rows.Cast().ToArray();
+ var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market);
+ var candles = new ComparisonCandlePoint[rows.Length];
+ DateOnly? previousDate = null;
+ for (var index = 0; index < rows.Length; index++)
+ {
+ var row = rows[index];
+ ComparisonAndYieldTableReader.RequireSameQuote(row, quote);
+ var date = ComparisonAndYieldTableReader.Date(row, "OPEN_DAY");
+ if (previousDate.HasValue && date <= previousDate.Value)
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ var high = ComparisonAndYieldTableReader.Double(row, "HIGH_VALUE");
+ var low = ComparisonAndYieldTableReader.Double(row, "LOW_VALUE");
+ var open = ComparisonAndYieldTableReader.Double(row, "OPEN_VALUE");
+ var close = ComparisonAndYieldTableReader.Double(row, "CLOSE_VALUE");
+ if (high < low)
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ candles[index] = new ComparisonCandlePoint(date, high, low, open, close);
+ previousDate = date;
+ }
+
+ return new ComparisonCandleSeriesData(quote, candles);
+ }
+
+ private static ComparisonYieldSeriesData MapComparisonYieldSeries(
+ DataTable? table,
+ ComparisonSeriesTarget target,
+ ComparisonEquityMarket market,
+ int rowLimit)
+ {
+ ComparisonAndYieldTableReader.RequireShape(
+ table,
+ rowLimit,
+ ComparisonYieldColumns);
+ var rows = table!.Rows.Cast().ToArray();
+ var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market);
+ var points = new ComparisonYieldPoint[rows.Length];
+ DateOnly? previousDate = null;
+ for (var index = 0; index < rows.Length; index++)
+ {
+ var row = rows[index];
+ ComparisonAndYieldTableReader.RequireSameQuote(row, quote);
+ var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY");
+ if (previousDate.HasValue && date >= previousDate.Value)
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ points[index] = new ComparisonYieldPoint(
+ date,
+ ComparisonAndYieldTableReader.Int32(row, "VALUE"));
+ previousDate = date;
+ }
+
+ return new ComparisonYieldSeriesData(quote, points);
+ }
+
+ private static IReadOnlyList MapSingleYield(
+ DataTable? table,
+ int rowLimit,
+ bool allowMissingValues)
+ {
+ ComparisonAndYieldTableReader.RequireShape(table, rowLimit, SingleYieldColumns);
+ var points = new YieldGraphPoint[rowLimit];
+ DateOnly? previousDate = null;
+ for (var index = 0; index < rowLimit; index++)
+ {
+ var row = table!.Rows[index];
+ var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY");
+ if (previousDate.HasValue && date >= previousDate.Value)
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ var value = ComparisonAndYieldTableReader.NullableDouble(row, "VALUE");
+ if ((!allowMissingValues && value is null) || (index == 0 && value is null))
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ points[index] = new YieldGraphPoint(date, value);
+ previousDate = date;
+ }
+
+ return points;
+ }
+
+ private static QuotedFxYieldResult MapQuotedFxYield(
+ DataTable? table,
+ int rowLimit)
+ {
+ ComparisonAndYieldTableReader.RequireShape(table, rowLimit, QuotedFxColumns);
+ var points = new YieldGraphPoint[rowLimit];
+ DateOnly? previousDate = null;
+ for (var index = 0; index < rowLimit; index++)
+ {
+ var row = table!.Rows[index];
+ var date = ComparisonAndYieldTableReader.Date(row, "DATA_DAY");
+ if (previousDate.HasValue && date >= previousDate.Value)
+ {
+ throw ComparisonAndYieldTableReader.InvalidResult();
+ }
+
+ points[index] = new YieldGraphPoint(
+ date,
+ ComparisonAndYieldTableReader.Double(row, "VALUE"));
+ previousDate = date;
+ }
+
+ var first = table!.Rows[0];
+ return new QuotedFxYieldResult(
+ ComparisonAndYieldTableReader.Double(first, "CHANGE_PRICE"),
+ ComparisonAndYieldTableReader.Double(first, "RATE"),
+ ComparisonAndYieldTableReader.YieldDirection(first, "DIRECTION"),
+ points);
+ }
+}
+
+internal sealed record QuotedFxYieldResult(
+ double ChangePrice,
+ double Rate,
+ YieldQuoteDirection Direction,
+ IReadOnlyList Points);
+
+internal static class ComparisonAndYieldLoadGuard
+{
+ public static ComparisonStockSelection[] Pair(ComparisonPairSceneLoadRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var first = ValidateSelection(request.First);
+ var second = ValidateSelection(request.Second);
+ return [first, second];
+ }
+
+ public static int ComparisonRowLimit(ComparisonGraphPeriod period) => period switch
+ {
+ ComparisonGraphPeriod.Daily => 1,
+ ComparisonGraphPeriod.FiveDays => 5,
+ ComparisonGraphPeriod.OneMonth => 20,
+ ComparisonGraphPeriod.ThreeMonths => 60,
+ ComparisonGraphPeriod.SixMonths => 120,
+ ComparisonGraphPeriod.TwelveMonths => 240,
+ _ => throw InvalidRequest()
+ };
+
+ public static int YieldRowLimit(YieldGraphPeriod period) => period switch
+ {
+ YieldGraphPeriod.FiveDays => 5,
+ YieldGraphPeriod.TwentyDays => 20,
+ YieldGraphPeriod.SixtyDays => 60,
+ YieldGraphPeriod.OneHundredTwentyDays => 120,
+ YieldGraphPeriod.TwoHundredFortyDays => 240,
+ _ => throw InvalidRequest()
+ };
+
+ public static void ValidateInstrument(YieldGraphInstrument? instrument)
+ {
+ var value = LegacySceneBuilderGuard.NotNull(instrument, "Instrument");
+ if (!Enum.IsDefined(value.Kind))
+ {
+ throw InvalidRequest();
+ }
+
+ var requiresName = value.Kind is
+ YieldGraphInstrumentKind.KospiIndustry or
+ YieldGraphInstrumentKind.KosdaqIndustry or
+ YieldGraphInstrumentKind.KospiStock or
+ YieldGraphInstrumentKind.KosdaqStock;
+ if (requiresName)
+ {
+ _ = LegacySceneBuilderGuard.Text(value.Name, "Instrument.Name");
+ }
+ else if (!string.IsNullOrEmpty(value.Name))
+ {
+ throw InvalidRequest();
+ }
+ }
+
+ public static bool IsFx(YieldGraphInstrumentKind kind) => kind is
+ YieldGraphInstrumentKind.WonDollar or
+ YieldGraphInstrumentKind.WonYen or
+ YieldGraphInstrumentKind.WonYuan or
+ YieldGraphInstrumentKind.WonEuro;
+
+ public static LegacySceneDataException InvalidRequest() =>
+ new("The comparison or yield request contains an unsupported closed selection.");
+
+ private static ComparisonStockSelection ValidateSelection(
+ ComparisonStockSelection? selection)
+ {
+ var value = LegacySceneBuilderGuard.NotNull(selection, "StockSelection");
+ if (!Enum.IsDefined(value.Market))
+ {
+ throw InvalidRequest();
+ }
+
+ return value with
+ {
+ StockName = LegacySceneBuilderGuard.Text(value.StockName, "StockName")
+ };
+ }
+}
+
+internal static class ComparisonAndYieldTableReader
+{
+ public static void RequireShape(
+ DataTable? table,
+ int expectedRows,
+ IReadOnlyList expectedColumns)
+ {
+ if (table is null || table.Rows.Count != expectedRows ||
+ table.Columns.Count != expectedColumns.Count)
+ {
+ throw InvalidResult();
+ }
+
+ var columns = table.Columns
+ .Cast()
+ .Select(column => column.ColumnName)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (expectedColumns.Any(column => !columns.Contains(column)))
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static ComparisonQuoteData ComparisonQuote(
+ DataRow row,
+ ComparisonSeriesTarget target,
+ ComparisonEquityMarket market)
+ {
+ var currentPrice = Int64(row, "CURRENT_PRICE");
+ var netChange = Int64(row, "NET_CHANGE");
+ if (currentPrice <= 0 || netChange < 0)
+ {
+ throw InvalidResult();
+ }
+
+ return new ComparisonQuoteData(
+ target,
+ market,
+ Text(row, "STOCK_NAME"),
+ currentPrice,
+ netChange,
+ Double(row, "RATE"),
+ ComparisonDirection(row, "DIRECTION"));
+ }
+
+ public static void RequireSameQuote(DataRow row, ComparisonQuoteData quote)
+ {
+ var candidate = ComparisonQuote(row, quote.Series, quote.Market);
+ if (candidate != quote)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static string Text(DataRow row, string column)
+ {
+ var value = Required(row, column);
+ try
+ {
+ return LegacySceneBuilderGuard.Text(
+ Convert.ToString(value, CultureInfo.InvariantCulture),
+ column);
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static DateOnly Date(DataRow row, string column)
+ {
+ var value = Required(row, column);
+ if (value is DateOnly dateOnly)
+ {
+ return dateOnly;
+ }
+
+ if (value is DateTime dateTime)
+ {
+ return DateOnly.FromDateTime(dateTime);
+ }
+
+ string? text;
+ try
+ {
+ text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+
+ return DateOnly.TryParseExact(
+ text,
+ "yyyyMMdd",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var parsed)
+ ? parsed
+ : throw InvalidResult();
+ }
+
+ public static long Int64(DataRow row, string column)
+ {
+ var value = Decimal(row, column);
+ if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue)
+ {
+ throw InvalidResult();
+ }
+
+ return decimal.ToInt64(value);
+ }
+
+ public static int Int32(DataRow row, string column)
+ {
+ var value = Decimal(row, column);
+ if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue)
+ {
+ throw InvalidResult();
+ }
+
+ return decimal.ToInt32(value);
+ }
+
+ public static double Double(DataRow row, string column)
+ {
+ double value;
+ try
+ {
+ value = Convert.ToDouble(Required(row, column), CultureInfo.InvariantCulture);
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+
+ return double.IsFinite(value) ? value : throw InvalidResult();
+ }
+
+ public static double? NullableDouble(DataRow row, string column)
+ {
+ var value = row[column];
+ return value is null or DBNull ? null : Double(row, column);
+ }
+
+ public static ComparisonChangeDirection ComparisonDirection(
+ DataRow row,
+ string column)
+ {
+ var value = DirectionText(row, column);
+ return value switch
+ {
+ "1" or "\uC0C1\uD55C" => ComparisonChangeDirection.LimitUp,
+ "2" or "+" or "\uC0C1\uC2B9" => ComparisonChangeDirection.Up,
+ "3" or "" or "\uBCF4\uD569" => ComparisonChangeDirection.Flat,
+ "5" or "-" or "\uD558\uB77D" => ComparisonChangeDirection.Down,
+ "4" or "\uD558\uD55C" => ComparisonChangeDirection.LimitDown,
+ _ => throw InvalidResult()
+ };
+ }
+
+ public static YieldQuoteDirection YieldDirection(DataRow row, string column) =>
+ DirectionText(row, column) switch
+ {
+ "+" or "1" or "2" or "\uC0C1\uD55C" or "\uC0C1\uC2B9" =>
+ YieldQuoteDirection.Up,
+ "" or "3" or "\uBCF4\uD569" => YieldQuoteDirection.Flat,
+ "-" or "4" or "5" or "\uD558\uD55C" or "\uD558\uB77D" =>
+ YieldQuoteDirection.Down,
+ _ => throw InvalidResult()
+ };
+
+ public static LegacySceneDataException InvalidResult() =>
+ new("The comparison or yield query returned an invalid result.");
+
+ private static decimal Decimal(DataRow row, string column)
+ {
+ try
+ {
+ return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture);
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ private static string DirectionText(DataRow row, string column)
+ {
+ if (row[column] is null or DBNull)
+ {
+ // Oracle materializes its empty-string flat marker as DBNull.
+ return string.Empty;
+ }
+
+ try
+ {
+ return Convert.ToString(
+ row[column],
+ CultureInfo.InvariantCulture)?.Trim() ?? string.Empty;
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ private static object Required(DataRow row, string column) =>
+ row[column] is { } value && value is not DBNull
+ ? value
+ : throw InvalidResult();
+}
+
+internal static class ComparisonAndYieldQueryFactory
+{
+ public static DataQuerySpec ComparisonCandle(ComparisonStockSelection selection)
+ {
+ var (online, stock, history) = EquityTables(selection.Market);
+ var sql = $"""
+ WITH SELECTED_STOCK AS (
+ SELECT :stockName STOCK_NAME FROM DUAL
+ ), QUOTE_ROW AS (
+ SELECT DISTINCT b.f_stock_wanname STOCK_NAME,
+ a.f_curr_price CURRENT_PRICE,
+ ABS(a.f_net_chg) NET_CHANGE,
+ {OracleEquityRate("a")} RATE,
+ a.f_chg_type DIRECTION
+ FROM {online} a
+ JOIN {stock} b ON a.f_stock_code = b.f_stock_code
+ JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
+ WHERE b.f_mkt_halt = 'N'
+ AND a.f_curr_price <> 0
+ ), OPEN_DAYS AS (
+ SELECT OPEN_DAY
+ FROM (
+ SELECT OPEN_DAY FROM v_open_day ORDER BY OPEN_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ ), CANDLE_ROWS AS (
+ SELECT h.f_data_date OPEN_DAY,
+ h.f_high_price HIGH_VALUE,
+ h.f_low_price LOW_VALUE,
+ h.f_init_price OPEN_VALUE,
+ h.f_curr_price CLOSE_VALUE
+ FROM {history} h
+ JOIN {stock} b ON h.f_stock_code = b.f_stock_code
+ JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
+ WHERE b.f_mkt_halt = 'N'
+ )
+ SELECT q.STOCK_NAME, q.CURRENT_PRICE, q.NET_CHANGE, q.RATE, q.DIRECTION,
+ d.OPEN_DAY, c.HIGH_VALUE, c.LOW_VALUE, c.OPEN_VALUE, c.CLOSE_VALUE
+ FROM QUOTE_ROW q
+ CROSS JOIN OPEN_DAYS d
+ LEFT JOIN CANDLE_ROWS c ON d.OPEN_DAY = c.OPEN_DAY
+ ORDER BY d.OPEN_DAY
+ """;
+ return new DataQuerySpec(
+ sql,
+ [
+ new DataQueryParameter("stockName", selection.StockName, DbType.String),
+ new DataQueryParameter("rowLimit", 5, DbType.Int32)
+ ]);
+ }
+
+ public static DataQuerySpec ComparisonYield(
+ ComparisonStockSelection selection,
+ int rowLimit)
+ {
+ var (online, stock, history) = EquityTables(selection.Market);
+ var sql = $"""
+ WITH SELECTED_STOCK AS (
+ SELECT :stockName STOCK_NAME FROM DUAL
+ ), CURRENT_DAY AS (
+ SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY
+ FROM t_index
+ WHERE f_part_code = '001'
+ ), QUOTE_ROW AS (
+ SELECT DISTINCT b.f_stock_wanname STOCK_NAME,
+ a.f_curr_price CURRENT_PRICE,
+ ABS(a.f_net_chg) NET_CHANGE,
+ {OracleEquityRate("a")} RATE,
+ a.f_chg_type DIRECTION
+ FROM {online} a
+ JOIN {stock} b ON a.f_stock_code = b.f_stock_code
+ JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
+ WHERE b.f_mkt_halt = 'N'
+ AND a.f_curr_price <> 0
+ ), STOCK_HISTORY AS (
+ SELECT h.f_data_date DATA_DAY, h.f_curr_price VALUE
+ FROM {history} h
+ JOIN {stock} b ON h.f_stock_code = b.f_stock_code
+ JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
+ WHERE b.f_mkt_halt = 'N'
+ ), RAW_POINTS AS (
+ SELECT d.open_day DATA_DAY, h.VALUE
+ FROM v_open_day d
+ LEFT JOIN STOCK_HISTORY h ON d.open_day = h.DATA_DAY
+ WHERE d.open_day <> (SELECT DATA_DAY FROM CURRENT_DAY)
+ UNION ALL
+ SELECT (SELECT DATA_DAY FROM CURRENT_DAY) DATA_DAY,
+ a.f_curr_price VALUE
+ FROM {online} a
+ JOIN {stock} b ON a.f_stock_code = b.f_stock_code
+ JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
+ WHERE b.f_mkt_halt = 'N'
+ AND a.f_curr_price <> 0
+ ), BOUNDED_POINTS AS (
+ SELECT DATA_DAY, VALUE
+ FROM (
+ SELECT DATA_DAY, VALUE FROM RAW_POINTS ORDER BY DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ )
+ SELECT q.STOCK_NAME, q.CURRENT_PRICE, q.NET_CHANGE, q.RATE, q.DIRECTION,
+ p.DATA_DAY, p.VALUE
+ FROM QUOTE_ROW q
+ CROSS JOIN BOUNDED_POINTS p
+ ORDER BY p.DATA_DAY DESC
+ """;
+ return new DataQuerySpec(
+ sql,
+ [
+ new DataQueryParameter("stockName", selection.StockName, DbType.String),
+ new DataQueryParameter("rowLimit", rowLimit, DbType.Int32)
+ ]);
+ }
+
+ public static DataQuerySpec SingleYield(
+ YieldGraphInstrument instrument,
+ int rowLimit)
+ {
+ var raw = RawSingleYield(instrument);
+ var sql = $"""
+ {raw.Prefix}
+ SELECT DATA_DAY, VALUE
+ FROM (
+ SELECT DATA_DAY, VALUE FROM RAW_POINTS ORDER BY DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ """;
+ var parameters = raw.Parameters.ToList();
+ parameters.Add(new DataQueryParameter("rowLimit", rowLimit, DbType.Int32));
+ return new DataQuerySpec(sql, parameters);
+ }
+
+ public static DataQuerySpec QuotedFxYield(
+ YieldGraphInstrument instrument,
+ int rowLimit)
+ {
+ var inputCode = FxCode(instrument.Kind);
+ var sql = $"""
+ WITH SELECTED_INPUT AS (
+ SELECT :inputCode INPUT_CODE FROM DUAL
+ ), RAW_POINTS AS (
+ SELECT a.f_data_day DATA_DAY,
+ a.f_curr_price VALUE,
+ a.f_net_chg CHANGE_PRICE,
+ {OracleFxRate("a")} RATE,
+ a.f_chg_type DIRECTION
+ FROM t_week_index a
+ JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE
+ WHERE a.f_data_day <> (
+ SELECT MAX(t.f_data_day)
+ FROM t_input_index t
+ JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE
+ )
+ AND a.f_data_day IN (SELECT open_day FROM v_open_day)
+ UNION ALL
+ SELECT a.f_data_day DATA_DAY,
+ a.f_curr_price VALUE,
+ a.f_net_chg CHANGE_PRICE,
+ {OracleFxRate("a")} RATE,
+ a.f_chg_type DIRECTION
+ FROM t_input_index a
+ JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE
+ WHERE a.f_data_day = (
+ SELECT MAX(t.f_data_day)
+ FROM t_input_index t
+ JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE
+ )
+ )
+ SELECT DATA_DAY, VALUE, CHANGE_PRICE, RATE, DIRECTION
+ FROM (
+ SELECT DATA_DAY, VALUE, CHANGE_PRICE, RATE, DIRECTION
+ FROM RAW_POINTS
+ ORDER BY DATA_DAY DESC
+ )
+ WHERE ROWNUM <= :rowLimit
+ """;
+ return new DataQuerySpec(
+ sql,
+ [
+ new DataQueryParameter("inputCode", inputCode, DbType.String),
+ new DataQueryParameter("rowLimit", rowLimit, DbType.Int32)
+ ]);
+ }
+
+ private static RawYieldQuery RawSingleYield(YieldGraphInstrument instrument) =>
+ instrument.Kind switch
+ {
+ YieldGraphInstrumentKind.WonDollar or
+ YieldGraphInstrumentKind.WonYen or
+ YieldGraphInstrumentKind.WonYuan or
+ YieldGraphInstrumentKind.WonEuro => RawFxYield(instrument.Kind),
+ YieldGraphInstrumentKind.KospiIndex => RawIndexYield(
+ "t_index_his_day", "t_index", "001"),
+ YieldGraphInstrumentKind.KosdaqIndex => RawIndexYield(
+ "t_kosdaq_index_his_day", "t_kosdaq_index", "001"),
+ YieldGraphInstrumentKind.Kospi200Index => RawIndexYield(
+ "t_200_index_his_day", "t_200_index", "029"),
+ YieldGraphInstrumentKind.Krx100Index => RawIndexYield(
+ "t_krx100_index_his_day", "t_krx100_index", "043"),
+ YieldGraphInstrumentKind.KospiIndustry => RawIndustryYield(
+ instrument.Name!,
+ "t_part",
+ "t_index_his",
+ "t_index"),
+ YieldGraphInstrumentKind.KosdaqIndustry => RawIndustryYield(
+ instrument.Name!,
+ "t_kosdaq_part",
+ "t_kosdaq_index_his",
+ "t_kosdaq_index"),
+ YieldGraphInstrumentKind.KospiStock => RawStockYield(
+ instrument.Name!,
+ "t_stock",
+ "t_candle_history",
+ "t_online1"),
+ YieldGraphInstrumentKind.KosdaqStock => RawStockYield(
+ instrument.Name!,
+ "t_kosdaq_stock",
+ "t_kosdaq_candle_history",
+ "t_kosdaq_online1"),
+ _ => throw ComparisonAndYieldLoadGuard.InvalidRequest()
+ };
+
+ private static RawYieldQuery RawFxYield(YieldGraphInstrumentKind kind)
+ {
+ var inputCode = FxCode(kind);
+ return new RawYieldQuery(
+ $"""
+ WITH SELECTED_INPUT AS (
+ SELECT :inputCode INPUT_CODE FROM DUAL
+ ), RAW_POINTS AS (
+ SELECT a.f_data_day DATA_DAY, a.f_curr_price VALUE
+ FROM t_week_index a
+ JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE
+ WHERE a.f_data_day <> (
+ SELECT MAX(t.f_data_day)
+ FROM t_input_index t
+ JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE
+ )
+ AND a.f_data_day IN (SELECT open_day FROM v_open_day)
+ UNION ALL
+ SELECT a.f_data_day DATA_DAY, a.f_curr_price VALUE
+ FROM t_input_index a
+ JOIN SELECTED_INPUT i ON a.f_input_code = i.INPUT_CODE
+ WHERE a.f_data_day = (
+ SELECT MAX(t.f_data_day)
+ FROM t_input_index t
+ JOIN SELECTED_INPUT i2 ON t.f_input_code = i2.INPUT_CODE
+ )
+ )
+ """,
+ [new DataQueryParameter("inputCode", inputCode, DbType.String)]);
+ }
+
+ private static RawYieldQuery RawIndexYield(
+ string historyTable,
+ string currentTable,
+ string partCode) => new(
+ $"""
+ WITH CURRENT_DAY AS (
+ SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY
+ FROM {currentTable}
+ WHERE f_part_code = '{partCode}'
+ ), RAW_POINTS AS (
+ SELECT h.f_data_day DATA_DAY, h.f_part_idx / 100 VALUE
+ FROM {historyTable} h
+ WHERE h.f_part_code = '{partCode}'
+ AND h.f_data_day <> (SELECT DATA_DAY FROM CURRENT_DAY)
+ AND RTRIM(h.f_data_day) IN (SELECT open_day FROM v_open_day)
+ UNION ALL
+ SELECT SUBSTR(c.f_data_time, 1, 8) DATA_DAY,
+ c.f_part_idx / 100 VALUE
+ FROM {currentTable} c
+ WHERE c.f_part_code = '{partCode}'
+ AND SUBSTR(c.f_data_time, 1, 8) = (
+ SELECT DATA_DAY FROM CURRENT_DAY
+ )
+ )
+ """,
+ []);
+
+ private static RawYieldQuery RawIndustryYield(
+ string name,
+ string partTable,
+ string historyTable,
+ string currentTable) => new(
+ $"""
+ WITH SELECTED_NAME AS (
+ SELECT :instrumentName INSTRUMENT_NAME FROM DUAL
+ ), SELECTED_PART AS (
+ SELECT p.f_part_code PART_CODE
+ FROM {partTable} p
+ JOIN SELECTED_NAME n ON p.f_part_name = n.INSTRUMENT_NAME
+ ), CURRENT_DAY AS (
+ SELECT MAX(SUBSTR(c.f_data_time, 1, 8)) DATA_DAY
+ FROM {currentTable} c
+ JOIN SELECTED_PART p ON c.f_part_code = p.PART_CODE
+ ), RAW_POINTS AS (
+ SELECT SUBSTR(h.f_data_time, 1, 8) DATA_DAY,
+ h.f_part_idx / 100 VALUE
+ FROM {historyTable} h
+ JOIN SELECTED_PART p ON h.f_part_code = p.PART_CODE
+ WHERE SUBSTR(h.f_data_time, 1, 8) <>
+ (SELECT DATA_DAY FROM CURRENT_DAY)
+ AND RTRIM(h.f_data_time) IN (
+ SELECT open_day || '1535' FROM v_open_day
+ )
+ UNION ALL
+ SELECT SUBSTR(c.f_data_time, 1, 8) DATA_DAY,
+ c.f_part_idx / 100 VALUE
+ FROM {currentTable} c
+ JOIN SELECTED_PART p ON c.f_part_code = p.PART_CODE
+ WHERE SUBSTR(c.f_data_time, 1, 8) =
+ (SELECT DATA_DAY FROM CURRENT_DAY)
+ )
+ """,
+ [new DataQueryParameter("instrumentName", name, DbType.String)]);
+
+ private static RawYieldQuery RawStockYield(
+ string name,
+ string stockTable,
+ string historyTable,
+ string onlineTable) => new(
+ $"""
+ WITH SELECTED_NAME AS (
+ SELECT :instrumentName INSTRUMENT_NAME FROM DUAL
+ ), CURRENT_DAY AS (
+ SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY
+ FROM t_index
+ WHERE f_part_code = '001'
+ ), STOCK_HISTORY AS (
+ SELECT h.f_data_date DATA_DAY, h.f_curr_price VALUE
+ FROM {historyTable} h
+ JOIN {stockTable} b ON h.f_stock_code = b.f_stock_code
+ JOIN SELECTED_NAME n ON b.f_stock_wanname = n.INSTRUMENT_NAME
+ WHERE b.f_mkt_halt = 'N'
+ ), RAW_POINTS AS (
+ SELECT d.open_day DATA_DAY, h.VALUE
+ FROM v_open_day d
+ LEFT JOIN STOCK_HISTORY h ON d.open_day = h.DATA_DAY
+ WHERE d.open_day <> (SELECT DATA_DAY FROM CURRENT_DAY)
+ UNION ALL
+ SELECT (SELECT DATA_DAY FROM CURRENT_DAY) DATA_DAY,
+ o.f_curr_price VALUE
+ FROM {onlineTable} o
+ JOIN {stockTable} b ON o.f_stock_code = b.f_stock_code
+ JOIN SELECTED_NAME n ON b.f_stock_wanname = n.INSTRUMENT_NAME
+ WHERE b.f_mkt_halt = 'N'
+ AND o.f_curr_price <> 0
+ )
+ """,
+ [new DataQueryParameter("instrumentName", name, DbType.String)]);
+
+ private static (string Online, string Stock, string History) EquityTables(
+ ComparisonEquityMarket market) => market switch
+ {
+ ComparisonEquityMarket.Kospi =>
+ ("t_online1", "t_stock", "t_candle_history"),
+ ComparisonEquityMarket.Kosdaq =>
+ ("t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_candle_history"),
+ _ => throw ComparisonAndYieldLoadGuard.InvalidRequest()
+ };
+
+ private static string FxCode(YieldGraphInstrumentKind kind) => kind switch
+ {
+ YieldGraphInstrumentKind.WonDollar => "A09",
+ YieldGraphInstrumentKind.WonYen => "A10",
+ YieldGraphInstrumentKind.WonYuan => "A90",
+ YieldGraphInstrumentKind.WonEuro => "A91",
+ _ => throw ComparisonAndYieldLoadGuard.InvalidRequest()
+ };
+
+ private static string OracleEquityRate(string alias) => $"""
+ ROUND(({alias}.f_net_chg /
+ DECODE({alias}.f_chg_type,
+ '1', {alias}.f_curr_price - {alias}.f_net_chg,
+ '2', {alias}.f_curr_price - {alias}.f_net_chg,
+ '3', {alias}.f_curr_price,
+ '4', -1 * ({alias}.f_curr_price + {alias}.f_net_chg),
+ '5', -1 * ({alias}.f_curr_price + {alias}.f_net_chg))) * 100, 2)
+ """;
+
+ private static string OracleFxRate(string alias) => $"""
+ ROUND(({alias}.f_net_chg /
+ DECODE({alias}.f_chg_type,
+ '+', {alias}.f_curr_price - {alias}.f_net_chg,
+ '', {alias}.f_curr_price,
+ '-', {alias}.f_curr_price + {alias}.f_net_chg,
+ 1)) * 100, 2)
+ """;
+
+ private sealed record RawYieldQuery(
+ string Prefix,
+ IReadOnlyList Parameters);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs
new file mode 100644
index 0000000..b7cc3d9
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/EquityPanelSceneDataLoaders.cs
@@ -0,0 +1,515 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record S5006DomesticSceneDataRequest(
+ LegacyDomesticEquityMarket Market,
+ string StockCode);
+
+public sealed class S5006DomesticSceneDataLoader
+{
+ private static readonly string[] Columns =
+ [
+ "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
+ "OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"
+ ];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5006DomesticSceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5006DomesticSceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var view = request.Market switch
+ {
+ LegacyDomesticEquityMarket.Kospi => "V_2",
+ LegacyDomesticEquityMarket.Kosdaq => "V_2_KOSDAQ",
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode);
+ var sql = $"""
+ SELECT
+ a.stock_name STOCK_NAME,
+ a.chg_type DIRECTION_CODE,
+ a.curr_price CURRENT_PRICE,
+ a.net_chg NET_CHANGE,
+ a.rate RATE,
+ a.init_price OPEN_PRICE,
+ DECODE(
+ a.final_price,
+ 0, ROUND(((a.init_price - a.base_price) / a.base_price) * 100, 2),
+ ROUND(((a.init_price - a.final_price) / a.final_price) * 100, 2)) OPEN_RATE,
+ a.high_price HIGH_PRICE,
+ DECODE(
+ a.final_price,
+ 0, ROUND(((a.high_price - a.base_price) / a.base_price) * 100, 2),
+ ROUND(((a.high_price - a.final_price) / a.final_price) * 100, 2)) HIGH_RATE,
+ a.low_price LOW_PRICE,
+ DECODE(
+ a.final_price,
+ 0, ROUND(((a.low_price - a.base_price) / a.base_price) * 100, 2),
+ ROUND(((a.low_price - a.final_price) / a.final_price) * 100, 2)) LOW_RATE
+ FROM {view} a
+ WHERE a.stock_code = :stockCode
+ """;
+ var spec = EquityPanelLoaderReader.Spec(
+ DataSourceKind.Oracle,
+ sql,
+ new DataQueryParameter("stockCode", stockCode, DbType.String));
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "S5006_DOMESTIC",
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ var row = EquityPanelLoaderReader.SingleRow(table, Columns);
+ var data = new S5006SceneData(
+ request.Market,
+ EquityPanelLoaderReader.Text(row, "STOCK_NAME"),
+ EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"),
+ EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"),
+ EquityPanelLoaderReader.Int64(row, "NET_CHANGE"),
+ EquityPanelLoaderReader.Decimal(row, "RATE"),
+ EquityPanelLoaderReader.Int64(row, "OPEN_PRICE"),
+ EquityPanelLoaderReader.Decimal(row, "OPEN_RATE"),
+ EquityPanelLoaderReader.Int64(row, "HIGH_PRICE"),
+ EquityPanelLoaderReader.Decimal(row, "HIGH_RATE"),
+ EquityPanelLoaderReader.Int64(row, "LOW_PRICE"),
+ EquityPanelLoaderReader.Decimal(row, "LOW_RATE"));
+ _ = new S5006SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+public sealed record S5011SceneDataRequest(
+ LegacyDomesticEquityMarket Market,
+ S5011DetailBranch Branch,
+ string StockCode);
+
+public sealed class S5011SceneDataLoader
+{
+ private static readonly string[] Columns =
+ [
+ "STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
+ "DETAIL_1", "DETAIL_2", "DETAIL_3", "DETAIL_4"
+ ];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5011SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5011SceneDataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ if (!Enum.IsDefined(request.Market) || !Enum.IsDefined(request.Branch))
+ {
+ throw EquityPanelLoaderReader.InvalidRequest();
+ }
+
+ var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode);
+ var plan = S5011QueryFactory.Create(request.Market, request.Branch, stockCode);
+ var table = await _executor.ExecuteAsync(
+ plan.Source,
+ "S5011_DETAIL",
+ plan.Spec,
+ cancellationToken).ConfigureAwait(false);
+ var row = EquityPanelLoaderReader.SingleRow(table, Columns);
+ S5011DetailData detail = request.Branch switch
+ {
+ S5011DetailBranch.FaceValue => new S5011FaceValueDetail(
+ EquityPanelLoaderReader.Int64(row, "DETAIL_1"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_2"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
+ S5011DetailBranch.Valuation => new S5011ValuationDetail(
+ EquityPanelLoaderReader.Decimal(row, "DETAIL_1"),
+ EquityPanelLoaderReader.Decimal(row, "DETAIL_2"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
+ S5011DetailBranch.Volume => new S5011VolumeDetail(
+ EquityPanelLoaderReader.Int64(row, "DETAIL_1"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_2"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
+ EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ var data = new S5011SceneData(
+ request.Market,
+ EquityPanelLoaderReader.Text(row, "STOCK_NAME"),
+ EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"),
+ EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"),
+ EquityPanelLoaderReader.Int64(row, "NET_CHANGE"),
+ EquityPanelLoaderReader.Decimal(row, "RATE"),
+ detail);
+ _ = new S5011SceneMutationBuilder().Build(data);
+ return data;
+ }
+}
+
+internal static class S5011QueryFactory
+{
+ public static QueryPlan Create(
+ LegacyDomesticEquityMarket market,
+ S5011DetailBranch branch,
+ string stockCode)
+ {
+ if (market is LegacyDomesticEquityMarket.NxtKospi or
+ LegacyDomesticEquityMarket.NxtKosdaq)
+ {
+ if (branch == S5011DetailBranch.Valuation)
+ {
+ // The legacy NXT query fabricated four zero valuation values.
+ throw EquityPanelLoaderReader.InvalidRequest();
+ }
+
+ return Nxt(market, branch, stockCode);
+ }
+
+ return Oracle(market, branch, stockCode);
+ }
+
+ private static QueryPlan Oracle(
+ LegacyDomesticEquityMarket market,
+ S5011DetailBranch branch,
+ string stockCode)
+ {
+ var source = market switch
+ {
+ LegacyDomesticEquityMarket.Kospi => new OracleSource(
+ "V_2", "t_online1", "t_stock", "T_CANDLE_HISTORY"),
+ LegacyDomesticEquityMarket.Kosdaq => new OracleSource(
+ "V_2_KOSDAQ", "t_kosdaq_online1", "t_kosdaq_stock",
+ "T_KOSDAQ_CANDLE_HISTORY"),
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ var sql = branch switch
+ {
+ S5011DetailBranch.FaceValue => $"""
+ SELECT
+ a.stock_name STOCK_NAME,
+ a.chg_type DIRECTION_CODE,
+ a.curr_price CURRENT_PRICE,
+ a.net_chg NET_CHANGE,
+ a.rate RATE,
+ a.list_price DETAIL_1,
+ a.capital_price DETAIL_2,
+ a.siga_price DETAIL_3,
+ b.rank_num DETAIL_4
+ FROM {source.View} a,
+ (SELECT
+ z.f_stock_code stock_code,
+ RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num
+ FROM {source.OnlineTable} y, {source.StockTable} z
+ WHERE y.f_stock_code = z.f_stock_code
+ AND z.f_mkt_halt = 'N') b
+ WHERE a.stock_code = b.stock_code
+ AND a.stock_code = :stockCode
+ """,
+ S5011DetailBranch.Valuation => $"""
+ SELECT
+ a.stock_name STOCK_NAME,
+ a.chg_type DIRECTION_CODE,
+ a.curr_price CURRENT_PRICE,
+ a.net_chg NET_CHANGE,
+ a.rate RATE,
+ a.pbr DETAIL_1,
+ a.per DETAIL_2,
+ a.bps DETAIL_3,
+ a.eps DETAIL_4
+ FROM {source.View} a
+ WHERE a.stock_code = :stockCode
+ """,
+ S5011DetailBranch.Volume => $"""
+ SELECT
+ a.stock_name STOCK_NAME,
+ a.chg_type DIRECTION_CODE,
+ a.curr_price CURRENT_PRICE,
+ a.net_chg NET_CHANGE,
+ a.rate RATE,
+ a.net_vol DETAIL_1,
+ a.net_turnover DETAIL_2,
+ NVL((
+ SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 5)
+ FROM (
+ SELECT F_CURR_PRICE
+ FROM (
+ SELECT F_CURR_PRICE
+ FROM {source.CandleTable}
+ WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day)
+ AND f_stock_code = :stockCode5
+ ORDER BY F_DATA_DATE DESC
+ )
+ WHERE ROWNUM < 5
+ ) recent
+ ), 0) DETAIL_3,
+ NVL((
+ SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 20)
+ FROM (
+ SELECT F_CURR_PRICE
+ FROM (
+ SELECT F_CURR_PRICE
+ FROM {source.CandleTable}
+ WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day)
+ AND f_stock_code = :stockCode20
+ ORDER BY F_DATA_DATE DESC
+ )
+ WHERE ROWNUM < 20
+ ) recent
+ ), 0) DETAIL_4
+ FROM {source.View} a
+ WHERE a.stock_code = :stockCode
+ """,
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ var parameters = branch == S5011DetailBranch.Volume
+ ? new[]
+ {
+ new DataQueryParameter("stockCode5", stockCode, DbType.String),
+ new DataQueryParameter("stockCode20", stockCode, DbType.String),
+ new DataQueryParameter("stockCode", stockCode, DbType.String)
+ }
+ : [new DataQueryParameter("stockCode", stockCode, DbType.String)];
+ return new QueryPlan(
+ DataSourceKind.Oracle,
+ EquityPanelLoaderReader.Spec(DataSourceKind.Oracle, sql, parameters));
+ }
+
+ private static QueryPlan Nxt(
+ LegacyDomesticEquityMarket market,
+ S5011DetailBranch branch,
+ string stockCode)
+ {
+ var source = market switch
+ {
+ LegacyDomesticEquityMarket.NxtKospi => new NxtSource(
+ "n_online", "n_stock"),
+ LegacyDomesticEquityMarket.NxtKosdaq => new NxtSource(
+ "n_kosdaq_online", "n_kosdaq_stock"),
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ var sql = branch switch
+ {
+ S5011DetailBranch.FaceValue => $"""
+ SELECT
+ b.f_stock_name STOCK_NAME,
+ a.f_chg_type DIRECTION_CODE,
+ a.f_curr_price CURRENT_PRICE,
+ a.f_net_chg NET_CHANGE,
+ ROUND((a.f_net_chg / CASE
+ WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
+ WHEN a.f_chg_type = '3' THEN a.f_curr_price
+ WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg)
+ END) * 100, 2) RATE,
+ b.f_list_price DETAIL_1,
+ b.f_capital_price DETAIL_2,
+ b.f_list_num * a.f_curr_price DETAIL_3,
+ b.rank_num DETAIL_4
+ FROM {source.OnlineTable} a
+ JOIN (
+ SELECT
+ z.f_stock_code,
+ z.f_stock_name,
+ z.f_list_price,
+ z.f_capital_price,
+ z.f_list_num,
+ RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num
+ FROM {source.OnlineTable} y
+ JOIN {source.StockTable} z ON y.f_stock_code = z.f_stock_code
+ WHERE z.f_stop_gubun = 'N'
+ ) b ON a.f_stock_code = b.f_stock_code
+ WHERE a.f_stock_code = @stockCode
+ """,
+ S5011DetailBranch.Volume => $"""
+ SELECT
+ z.f_stock_name STOCK_NAME,
+ a.f_chg_type DIRECTION_CODE,
+ a.f_curr_price CURRENT_PRICE,
+ a.f_net_chg NET_CHANGE,
+ ROUND((a.f_net_chg / CASE
+ WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
+ WHEN a.f_chg_type = '3' THEN a.f_curr_price
+ WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg)
+ END) * 100, 2) RATE,
+ a.f_net_vol DETAIL_1,
+ a.f_net_turnover DETAIL_2,
+ 0 DETAIL_3,
+ 0 DETAIL_4
+ FROM {source.OnlineTable} a
+ JOIN {source.StockTable} z ON a.f_stock_code = z.f_stock_code
+ WHERE a.f_stock_code = @stockCode
+ """,
+ _ => throw EquityPanelLoaderReader.InvalidRequest()
+ };
+ return new QueryPlan(
+ DataSourceKind.MariaDb,
+ EquityPanelLoaderReader.Spec(
+ DataSourceKind.MariaDb,
+ sql,
+ new DataQueryParameter("stockCode", stockCode, DbType.String)));
+ }
+
+ internal sealed record QueryPlan(DataSourceKind Source, DataQuerySpec Spec);
+
+ private sealed record OracleSource(
+ string View,
+ string OnlineTable,
+ string StockTable,
+ string CandleTable);
+
+ private sealed record NxtSource(string OnlineTable, string StockTable);
+}
+
+internal static class EquityPanelLoaderReader
+{
+ private const int MaximumTextLength = 256;
+
+ public static DataQuerySpec Spec(
+ DataSourceKind source,
+ string sql,
+ params DataQueryParameter[] parameters)
+ {
+ var spec = new DataQuerySpec(sql, parameters);
+ spec.ValidateFor(source);
+ return spec;
+ }
+
+ public static string StockCode(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
+ value.Any(character => !char.IsAsciiLetterOrDigit(character)))
+ {
+ throw InvalidRequest();
+ }
+
+ return value;
+ }
+
+ public static DataRow SingleRow(DataTable? table, IReadOnlyList columns)
+ {
+ ExactSchema(table, columns);
+ if (table!.Rows.Count != 1)
+ {
+ throw InvalidResult();
+ }
+
+ return table.Rows[0];
+ }
+
+ public static void ExactSchema(DataTable? table, IReadOnlyList columns)
+ {
+ if (table is null || table.Columns.Count != columns.Count)
+ {
+ throw InvalidResult();
+ }
+
+ for (var index = 0; index < columns.Count; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ columns[index],
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw InvalidResult();
+ }
+ }
+ }
+
+ public static string Text(DataRow row, string column, bool allowNull = false)
+ {
+ var value = row[column];
+ if (value is null or DBNull)
+ {
+ if (allowNull)
+ {
+ return string.Empty;
+ }
+
+ throw InvalidResult();
+ }
+
+ var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
+ if (text is null || text.Length > MaximumTextLength || text.Any(char.IsControl) ||
+ (!allowNull && text.Length == 0))
+ {
+ throw InvalidResult();
+ }
+
+ return text;
+ }
+
+ public static object Value(DataRow row, string column) =>
+ row[column] is { } value && value is not DBNull ? value : throw InvalidResult();
+
+ public static long Int64(DataRow row, string column)
+ {
+ try
+ {
+ return checked(Convert.ToInt64(Value(row, column), CultureInfo.InvariantCulture));
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static decimal Decimal(DataRow row, string column)
+ {
+ try
+ {
+ return Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture);
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static double Double(DataRow row, string column)
+ {
+ try
+ {
+ var result = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture);
+ return double.IsFinite(result) ? result : throw InvalidResult();
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static ScenePriceDirection Direction(DataRow row, string column) =>
+ Direction(Text(row, column, allowNull: true));
+
+ public static ScenePriceDirection Direction(string? value) => value?.Trim() switch
+ {
+ "\uC0C1\uD55C" or "1" => ScenePriceDirection.LimitUp,
+ "\uC0C1\uC2B9" or "+" or "2" => ScenePriceDirection.Up,
+ null or "" or "\uBCF4\uD569" or "3" => ScenePriceDirection.Flat,
+ "\uD558\uB77D" or "-" or "5" => ScenePriceDirection.Down,
+ "\uD558\uD55C" or "4" => ScenePriceDirection.LimitDown,
+ _ => throw InvalidResult()
+ };
+
+ public static LegacySceneDataException InvalidRequest() =>
+ new("The panel scene data request is unsupported or invalid.");
+
+ public static LegacySceneDataException InvalidResult() =>
+ new("The panel scene query returned invalid data or an unexpected schema.");
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs
new file mode 100644
index 0000000..9fb0a4b
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs
@@ -0,0 +1,457 @@
+#nullable enable
+
+using System.Globalization;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record RevenueSliceData(
+ string Label,
+ string PercentageText,
+ double Percentage);
+
+public sealed record S5076SceneData(
+ string StockName,
+ string Basis,
+ IReadOnlyList Slices) : ILegacySceneData;
+
+public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5076";
+
+ public IReadOnlyList Build(S5076SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
+ var basis = LegacySceneBuilderGuard.Text(data.Basis, nameof(data.Basis));
+ var slices = LegacySceneBuilderGuard.Count(data.Slices, 5, nameof(data.Slices));
+ var mutations = new List(34)
+ {
+ new PlayoutSetValue("title", stockName + " 주요매출 구성"),
+ new PlayoutSetValue("date", basis + " 기준")
+ };
+
+ for (var index = 1; index <= 5; index++)
+ {
+ mutations.Add(new PlayoutSetVisible("circle" + index, false));
+ mutations.Add(new PlayoutSetVisible("group" + index, false));
+ }
+
+ double startAngle = 0;
+ for (var index = 1; index <= 5; index++)
+ {
+ var slice = slices[index - 1];
+ if (slice is null || string.IsNullOrEmpty(slice.Label))
+ {
+ continue;
+ }
+
+ var label = LegacySceneBuilderGuard.Text(slice.Label, $"Slices[{index - 1}].Label");
+ var percentageText = LegacySceneBuilderGuard.Text(
+ slice.PercentageText,
+ $"Slices[{index - 1}].PercentageText");
+ if (!double.IsFinite(slice.Percentage))
+ {
+ throw new LegacySceneDataException("Revenue percentage must be finite.");
+ }
+
+ mutations.Add(new PlayoutSetVisible("circle" + index, true));
+ mutations.Add(new PlayoutSetVisible("group" + index, true));
+ mutations.Add(new PlayoutSetValue("word" + index, label));
+ mutations.Add(new PlayoutSetValue("percent" + index, percentageText));
+
+ var angle = slice.Percentage / 100d * 360d;
+ mutations.Add(new PlayoutSetCircleAngleKey(
+ "circle" + index,
+ 0,
+ (float)startAngle,
+ (float)startAngle,
+ PlayoutAngleComponents.All));
+ mutations.Add(new PlayoutSetCircleAngleKey(
+ "circle" + index,
+ 1,
+ (float)startAngle,
+ (float)(startAngle + angle),
+ PlayoutAngleComponents.All));
+ startAngle += angle;
+ }
+
+ return mutations;
+ }
+}
+
+public sealed record QuarterlyProfitData(string Quarter, int Value);
+
+public sealed record S5081SceneData(
+ string StockName,
+ IReadOnlyList Quarters) : ILegacySceneData;
+
+public sealed class S5081SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5081";
+
+ public IReadOnlyList Build(S5081SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
+ var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters));
+ var mutations = new List(38)
+ {
+ new PlayoutSetValue("title", stockName + " 영업이익")
+ };
+
+ for (var index = 1; index <= 6; index++)
+ {
+ var quarter = LegacySceneBuilderGuard.NotNull(
+ quarters[index - 1],
+ $"Quarters[{index - 1}]");
+ mutations.Add(new PlayoutSetValue(
+ "quarter" + index,
+ LegacySceneBuilderGuard.Text(quarter.Quarter, $"Quarters[{index - 1}].Quarter")));
+ }
+
+ var values = quarters.Select(item => item.Value).ToArray();
+ for (var index = 1; index <= 6; index++)
+ {
+ var value = values[index - 1];
+ var variant = value < 0 ? 2 : 1;
+ mutations.Add(new PlayoutSetVisible($"barG{index}_1", false));
+ mutations.Add(new PlayoutSetVisible($"barG{index}_2", false));
+ mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true));
+ mutations.Add(new PlayoutSetValue(
+ $"value{index}_{variant}",
+ value.ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+
+ var maximum = values.Max();
+ var minimum = values.Min();
+ var allPositive = maximum > 0 && minimum > 0;
+ var allNegative = maximum < 0 && minimum < 0;
+ var center = allPositive
+ ? -240f
+ : allNegative
+ ? 115f
+ : SafeFloat((355d * Math.Abs(minimum) / (maximum + Math.Abs((double)minimum))) - 250d, -250f);
+ mutations.Add(new PlayoutSetPositionKey(
+ "centerbar",
+ 0,
+ 0,
+ center,
+ 0,
+ PlayoutVectorComponents.Y));
+
+ for (var index = 1; index <= 6; index++)
+ {
+ var value = values[index - 1];
+ var denominator = allPositive
+ ? maximum
+ : allNegative
+ ? minimum
+ : maximum - minimum;
+ var scale = denominator == 0
+ ? 0f
+ : (float)(1.8d * Math.Abs(value / (double)denominator));
+ var variant = value < 0 ? 2 : 1;
+ mutations.Add(new PlayoutSetScale(
+ $"bar{index}_{variant}",
+ 0,
+ scale,
+ 0,
+ PlayoutVectorComponents.Y));
+ }
+
+ return mutations;
+ }
+
+ private static float SafeFloat(double value, float fallback) =>
+ double.IsFinite(value) ? (float)value : fallback;
+}
+
+public enum ProgramTradingMetric
+{
+ NetBuy,
+ Arbitrage,
+ NonArbitrage,
+ Basis,
+ Kospi200,
+ Futures
+}
+
+public sealed record ProgramTradingMetricData(ProgramTradingMetric Metric, double Value);
+
+public sealed record S5085SceneData(
+ IReadOnlyList Metrics) : ILegacySceneData;
+
+public sealed class S5085SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5085";
+
+ public IReadOnlyList Build(S5085SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var metrics = LegacySceneBuilderGuard.Range(data.Metrics, 1, 6, nameof(data.Metrics));
+ if (metrics.Select(item => item.Metric).Distinct().Count() != metrics.Count)
+ {
+ throw new LegacySceneDataException("Program trading metrics must be unique.");
+ }
+
+ var mutations = new List(metrics.Count * 3);
+ foreach (var metric in metrics)
+ {
+ if (!double.IsFinite(metric.Value))
+ {
+ throw new LegacySceneDataException("Program trading value must be finite.");
+ }
+
+ var (index, title) = metric.Metric switch
+ {
+ ProgramTradingMetric.NetBuy => (1, "순매수"),
+ ProgramTradingMetric.Arbitrage => (2, "차익"),
+ ProgramTradingMetric.NonArbitrage => (3, "비차익"),
+ ProgramTradingMetric.Basis => (4, "베이시스"),
+ ProgramTradingMetric.Kospi200 => (5, "코스피200"),
+ ProgramTradingMetric.Futures => (6, "선물"),
+ _ => throw new LegacySceneDataException("Unknown program trading metric.")
+ };
+ var color = SignedColor(metric.Value, (171, 0, 0), (15, 99, 188), (47, 47, 47));
+ mutations.Add(new PlayoutSetValue("title" + index, title));
+ mutations.Add(new PlayoutSetFaceColor("price" + index, color.R, color.G, color.B));
+ mutations.Add(new PlayoutSetValue(
+ "price" + index,
+ metric.Value.ToString("#,##0.##", CultureInfo.InvariantCulture)));
+ }
+
+ return mutations;
+ }
+
+ private static (int R, int G, int B) SignedColor(
+ double value,
+ (int R, int G, int B) positive,
+ (int R, int G, int B) negative,
+ (int R, int G, int B) zero) => value > 0 ? positive : value < 0 ? negative : zero;
+}
+
+public enum InstitutionMarket
+{
+ Kospi,
+ Kosdaq,
+ Other
+}
+
+public enum InstitutionKind
+{
+ Securities,
+ Insurance,
+ InvestmentTrust,
+ Bank,
+ Fund
+}
+
+public sealed record InstitutionNetBuyData(
+ InstitutionMarket Market,
+ InstitutionKind Institution,
+ int Value);
+
+public sealed record S6067SceneData(
+ IReadOnlyList Rows) : ILegacySceneData;
+
+public sealed class S6067SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s6067";
+
+ public IReadOnlyList Build(S6067SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var rows = LegacySceneBuilderGuard.Range(data.Rows, 1, 15, nameof(data.Rows));
+ var mutations = new List(rows.Count * 2);
+ foreach (var row in rows)
+ {
+ var market = row.Market switch
+ {
+ InstitutionMarket.Kospi => 1,
+ InstitutionMarket.Kosdaq => 2,
+ InstitutionMarket.Other => 3,
+ _ => throw new LegacySceneDataException("Unknown institution market.")
+ };
+ var institution = row.Institution switch
+ {
+ InstitutionKind.Securities => 1,
+ InstitutionKind.Insurance => 2,
+ InstitutionKind.InvestmentTrust => 3,
+ InstitutionKind.Bank => 4,
+ InstitutionKind.Fund => 5,
+ _ => throw new LegacySceneDataException("Unknown institution type.")
+ };
+ var name = $"row{market}_value{institution}";
+ var color = row.Value > 0
+ ? (R: 171, G: 0, B: 0)
+ : row.Value < 0
+ ? (R: 15, G: 99, B: 188)
+ : (R: 47, G: 47, B: 47);
+ mutations.Add(new PlayoutSetFaceColor(name, color.R, color.G, color.B));
+ mutations.Add(new PlayoutSetValue(
+ name,
+ row.Value.ToString("#,##0", CultureInfo.InvariantCulture)));
+ }
+
+ return mutations;
+ }
+}
+
+public enum WorldMarket
+{
+ Korea,
+ China,
+ HongKong,
+ Japan,
+ Taiwan,
+ Dow,
+ Nasdaq,
+ StandardAndPoor,
+ UnitedKingdom,
+ Germany,
+ France
+}
+
+public enum SceneMarketTrend
+{
+ Up,
+ Flat,
+ Down
+}
+
+public sealed record WorldMarketQuoteData(
+ WorldMarket Market,
+ double CurrentPrice,
+ double NetChange,
+ double Rate,
+ SceneMarketTrend Trend);
+
+public sealed record S8067SceneData(
+ IReadOnlyList Quotes) : ILegacySceneData;
+
+public sealed class S8067SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s8067";
+
+ public IReadOnlyList Build(S8067SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var quotes = LegacySceneBuilderGuard.Range(data.Quotes, 1, 11, nameof(data.Quotes));
+ if (quotes.Select(item => item.Market).Distinct().Count() != quotes.Count)
+ {
+ throw new LegacySceneDataException("World market quotes must be unique.");
+ }
+
+ var mutations = new List(quotes.Count * 7);
+ foreach (var quote in quotes)
+ {
+ if (!double.IsFinite(quote.CurrentPrice) || !double.IsFinite(quote.NetChange) ||
+ !double.IsFinite(quote.Rate))
+ {
+ throw new LegacySceneDataException("World market quote values must be finite.");
+ }
+
+ var target = NameOf(quote.Market);
+ mutations.Add(new PlayoutSetValue(
+ "price_" + target,
+ quote.CurrentPrice.ToString("#,##0.###", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetValue(
+ "rate_" + target,
+ quote.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
+ mutations.Add(new PlayoutSetVisible("up_" + target, false));
+ mutations.Add(new PlayoutSetVisible("down_" + target, false));
+ mutations.Add(new PlayoutSetVisible("flat_" + target, false));
+
+ if (quote.Trend == SceneMarketTrend.Up)
+ {
+ mutations.Add(new PlayoutSetFaceColor("bg_" + target, 170, 0, 0));
+ mutations.Add(new PlayoutSetVisible("up_" + target, true));
+ }
+ else if (quote.Trend == SceneMarketTrend.Down && quote.NetChange != 0)
+ {
+ mutations.Add(new PlayoutSetFaceColor("bg_" + target, 1, 101, 255));
+ mutations.Add(new PlayoutSetVisible("down_" + target, true));
+ }
+ else
+ {
+ // Preserve the original global "bg" target used for flat rows.
+ mutations.Add(new PlayoutSetFaceColor("bg", 191, 191, 191));
+ mutations.Add(new PlayoutSetVisible("flat_" + target, true));
+ }
+ }
+
+ return mutations;
+ }
+
+ private static string NameOf(WorldMarket market) => market switch
+ {
+ WorldMarket.Korea => "한국",
+ WorldMarket.China => "중국",
+ WorldMarket.HongKong => "홍콩",
+ WorldMarket.Japan => "일본",
+ WorldMarket.Taiwan => "대만",
+ WorldMarket.Dow => "다우",
+ WorldMarket.Nasdaq => "나스닥",
+ WorldMarket.StandardAndPoor => "S&P",
+ WorldMarket.UnitedKingdom => "영국",
+ WorldMarket.Germany => "독일",
+ WorldMarket.France => "프랑스",
+ _ => throw new LegacySceneDataException("Unknown world market.")
+ };
+}
+
+public enum CommodityUnit
+{
+ DollarsPerBarrel,
+ DollarsPerOunce
+}
+
+public sealed record S8086SceneData(
+ string Title,
+ CommodityUnit Unit,
+ double CurrentPrice,
+ double ChangePrice,
+ double Rate,
+ SceneMarketTrend Trend) : ILegacySceneData;
+
+public sealed class S8086SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s8086";
+
+ public IReadOnlyList Build(S8086SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
+ if (!double.IsFinite(data.CurrentPrice) || !double.IsFinite(data.ChangePrice) ||
+ !double.IsFinite(data.Rate))
+ {
+ throw new LegacySceneDataException("Commodity quote values must be finite.");
+ }
+
+ var unit = data.Unit switch
+ {
+ CommodityUnit.DollarsPerBarrel => "단위: 달러/배럴",
+ CommodityUnit.DollarsPerOunce => "단위: 달러/온스",
+ _ => throw new LegacySceneDataException("Unknown commodity unit.")
+ };
+ var color = data.Trend switch
+ {
+ SceneMarketTrend.Up => (R: 172, G: 0, B: 0),
+ SceneMarketTrend.Down => (R: 14, G: 100, B: 188),
+ SceneMarketTrend.Flat => (R: 95, G: 95, B: 95),
+ _ => throw new LegacySceneDataException("Unknown commodity trend.")
+ };
+
+ return
+ [
+ new PlayoutSetValue("unit", unit),
+ new PlayoutSetValue("title", title),
+ new PlayoutSetValue("price", data.CurrentPrice.ToString("#,##0.00", CultureInfo.InvariantCulture)),
+ new PlayoutSetValue("changePrice", data.ChangePrice.ToString("#,##0.00", CultureInfo.InvariantCulture)),
+ new PlayoutSetValue("rate", data.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)),
+ new PlayoutSetFaceColor("bg", color.R, color.G, color.B),
+ new PlayoutSetFaceColor("changePrice", color.R, color.G, color.B),
+ new PlayoutSetFaceColor("rate", color.R, color.G, color.B)
+ ];
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs
new file mode 100644
index 0000000..2b545ca
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridMarketSceneDataLoaders.cs
@@ -0,0 +1,662 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record S5023SceneLoadRequest(LegacyDomesticMarket Market);
+
+public sealed class S5023SceneDataLoader
+{
+ private static readonly string[] DailyColumns =
+ ["TRADING_DATE", "INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
+ private static readonly string[] TotalColumns =
+ ["INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5023SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5023SceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var plan = GridMarketSceneQueries.S5023(request.Market);
+ var dailyTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_5023_DAILY",
+ plan.Daily,
+ cancellationToken).ConfigureAwait(false);
+ var totalTable = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_5023_MONTHLY",
+ plan.Monthly,
+ cancellationToken).ConfigureAwait(false);
+
+ var dailyRows = LegacyTabularSceneLoaderReader.ExactRows(dailyTable, 5, DailyColumns);
+ var mappedRows = new DailyInvestorFlowData?[dailyRows.Count];
+ DateOnly? previous = null;
+ for (var index = 0; index < dailyRows.Count; index++)
+ {
+ var row = dailyRows[index];
+ var date = LegacyTabularSceneLoaderReader.Date(row, "TRADING_DATE");
+ if (previous.HasValue && date >= previous.Value)
+ {
+ throw LegacyTabularSceneLoaderReader.InvalidResult();
+ }
+
+ previous = date;
+ mappedRows[index] = new DailyInvestorFlowData(
+ date,
+ new InvestorFlowAmounts(
+ LegacyTabularSceneLoaderReader.Int32(row, "INDIVIDUAL_AMOUNT"),
+ LegacyTabularSceneLoaderReader.Int32(row, "FOREIGN_AMOUNT"),
+ LegacyTabularSceneLoaderReader.Int32(row, "INSTITUTION_AMOUNT")));
+ }
+
+ var total = LegacyTabularSceneLoaderReader.ExactRows(totalTable, 1, TotalColumns)[0];
+ var data = new S5023SceneData(
+ request.Market,
+ mappedRows,
+ new InvestorFlowAmounts(
+ LegacyTabularSceneLoaderReader.Int32(total, "INDIVIDUAL_AMOUNT"),
+ LegacyTabularSceneLoaderReader.Int32(total, "FOREIGN_AMOUNT"),
+ LegacyTabularSceneLoaderReader.Int32(total, "INSTITUTION_AMOUNT")));
+ return LegacyTabularSceneLoaderReader.Preflight(new S5023SceneMutationBuilder(), data);
+ }
+}
+
+public sealed record S5024SceneLoadRequest(LegacyDomesticMarket Market);
+
+public sealed class S5024SceneDataLoader
+{
+ private static readonly string[] Columns = ["PARTICIPANT_CODE", "AMOUNT"];
+ private static readonly string[] ExpectedCodes = ["INDIVIDUAL", "FOREIGN", "INSTITUTION"];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5024SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ S5024SceneLoadRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_5024",
+ GridMarketSceneQueries.S5024(request.Market),
+ cancellationToken).ConfigureAwait(false);
+ var rows = LegacyTabularSceneLoaderReader.ExactRows(table, 3, Columns);
+ var amounts = new InvestorTradingAmountData[rows.Count];
+
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var code = LegacyTabularSceneLoaderReader.Text(rows[index], "PARTICIPANT_CODE");
+ if (!string.Equals(code, ExpectedCodes[index], StringComparison.Ordinal))
+ {
+ throw LegacyTabularSceneLoaderReader.InvalidResult();
+ }
+
+ var participant = code switch
+ {
+ "INDIVIDUAL" => InvestorParticipant.Individual,
+ "FOREIGN" => InvestorParticipant.Foreign,
+ "INSTITUTION" => InvestorParticipant.Institution,
+ _ => throw LegacyTabularSceneLoaderReader.InvalidResult()
+ };
+ amounts[index] = new InvestorTradingAmountData(
+ participant,
+ LegacyTabularSceneLoaderReader.Double(rows[index], "AMOUNT"));
+ }
+
+ var data = new S5024SceneData(request.Market, amounts);
+ return LegacyTabularSceneLoaderReader.Preflight(new S5024SceneMutationBuilder(), data);
+ }
+}
+
+public sealed class S5085SceneDataLoader
+{
+ private static readonly string[] Columns = ["METRIC_CODE", "METRIC_VALUE"];
+ private static readonly (string Code, ProgramTradingMetric Metric)[] Expected =
+ [
+ ("NET_BUY", ProgramTradingMetric.NetBuy),
+ ("ARBITRAGE", ProgramTradingMetric.Arbitrage),
+ ("NON_ARBITRAGE", ProgramTradingMetric.NonArbitrage),
+ ("BASIS", ProgramTradingMetric.Basis),
+ ("KOSPI200", ProgramTradingMetric.Kospi200),
+ ("FUTURES", ProgramTradingMetric.Futures)
+ ];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5085SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(CancellationToken cancellationToken = default)
+ {
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_5085",
+ GridMarketSceneQueries.S5085,
+ cancellationToken).ConfigureAwait(false);
+ var rows = LegacyTabularSceneLoaderReader.ExactRows(table, Expected.Length, Columns);
+ var metrics = new ProgramTradingMetricData[rows.Count];
+
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var code = LegacyTabularSceneLoaderReader.Text(rows[index], "METRIC_CODE");
+ if (!string.Equals(code, Expected[index].Code, StringComparison.Ordinal))
+ {
+ throw LegacyTabularSceneLoaderReader.InvalidResult();
+ }
+
+ metrics[index] = new ProgramTradingMetricData(
+ Expected[index].Metric,
+ LegacyTabularSceneLoaderReader.Double(rows[index], "METRIC_VALUE"));
+ }
+
+ var data = new S5085SceneData(metrics);
+ return LegacyTabularSceneLoaderReader.Preflight(new S5085SceneMutationBuilder(), data);
+ }
+}
+
+public sealed class S6067SceneDataLoader
+{
+ private static readonly string[] Columns = ["MARKET_CODE", "INSTITUTION_CODE", "AMOUNT"];
+ private static readonly (string MarketCode, InstitutionMarket Market)[] Markets =
+ [
+ ("KOSPI", InstitutionMarket.Kospi),
+ ("KOSDAQ", InstitutionMarket.Kosdaq),
+ ("KOSPI200", InstitutionMarket.Other)
+ ];
+ private static readonly (string InstitutionCode, InstitutionKind Institution)[] Institutions =
+ [
+ ("SECURITIES", InstitutionKind.Securities),
+ ("INSURANCE", InstitutionKind.Insurance),
+ ("INVESTMENT_TRUST", InstitutionKind.InvestmentTrust),
+ ("BANK", InstitutionKind.Bank),
+ ("FUND", InstitutionKind.Fund)
+ ];
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S6067SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(CancellationToken cancellationToken = default)
+ {
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_6067",
+ GridMarketSceneQueries.S6067,
+ cancellationToken).ConfigureAwait(false);
+ var rows = LegacyTabularSceneLoaderReader.ExactRows(
+ table,
+ Markets.Length * Institutions.Length,
+ Columns);
+ var mapped = new InstitutionNetBuyData[rows.Count];
+
+ for (var index = 0; index < rows.Count; index++)
+ {
+ var marketIndex = index / Institutions.Length;
+ var institutionIndex = index % Institutions.Length;
+ var row = rows[index];
+ var marketCode = LegacyTabularSceneLoaderReader.Text(row, "MARKET_CODE");
+ var institutionCode = LegacyTabularSceneLoaderReader.Text(row, "INSTITUTION_CODE");
+ if (!string.Equals(marketCode, Markets[marketIndex].MarketCode, StringComparison.Ordinal) ||
+ !string.Equals(
+ institutionCode,
+ Institutions[institutionIndex].InstitutionCode,
+ StringComparison.Ordinal))
+ {
+ throw LegacyTabularSceneLoaderReader.InvalidResult();
+ }
+
+ mapped[index] = new InstitutionNetBuyData(
+ Markets[marketIndex].Market,
+ Institutions[institutionIndex].Institution,
+ LegacyTabularSceneLoaderReader.Int32(row, "AMOUNT"));
+ }
+
+ var data = new S6067SceneData(mapped);
+ return LegacyTabularSceneLoaderReader.Preflight(new S6067SceneMutationBuilder(), data);
+ }
+}
+
+internal static class GridMarketSceneQueries
+{
+ public static (DataQuerySpec Daily, DataQuerySpec Monthly) S5023(LegacyDomesticMarket market)
+ {
+ var (currentTable, historyTable) = market switch
+ {
+ LegacyDomesticMarket.Kospi => ("t_invest", "t_invest_his"),
+ LegacyDomesticMarket.Kosdaq => ("t_kosdaq_invest", "t_kosdaq_invest_his"),
+ _ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
+ };
+
+ var current = CurrentInvestorFlow(currentTable);
+ var history = HistoryInvestorFlow(historyTable, 5);
+ var monthlyHistory = HistoryInvestorFlow(historyTable, 20);
+ var daily = $"""
+ SELECT
+ trading_date TRADING_DATE,
+ individual_amount INDIVIDUAL_AMOUNT,
+ foreign_amount FOREIGN_AMOUNT,
+ institution_amount INSTITUTION_AMOUNT
+ FROM (
+ {current}
+ UNION
+ {history}
+ )
+ ORDER BY trading_date DESC
+ """;
+ var monthly = $"""
+ SELECT
+ SUM(individual_amount) INDIVIDUAL_AMOUNT,
+ SUM(foreign_amount) FOREIGN_AMOUNT,
+ SUM(institution_amount) INSTITUTION_AMOUNT
+ FROM (
+ {current}
+ UNION
+ {monthlyHistory}
+ )
+ """;
+ return (Oracle(daily), Oracle(monthly));
+ }
+
+ public static DataQuerySpec S5024(LegacyDomesticMarket market)
+ {
+ var table = market switch
+ {
+ LegacyDomesticMarket.Kospi => "t_invest",
+ LegacyDomesticMarket.Kosdaq => "t_kosdaq_invest",
+ _ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
+ };
+ return Oracle($"""
+ SELECT participant_code PARTICIPANT_CODE, amount AMOUNT
+ FROM (
+ SELECT
+ DECODE(
+ f_invest_code,
+ '8000', 'INDIVIDUAL',
+ '9000', 'FOREIGN',
+ '9001', 'FOREIGN',
+ 'INSTITUTION') participant_code,
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
+ FROM {table}
+ WHERE f_part_code = '001'
+ AND f_invest_code IN (
+ '1000', '2000', '3000', '3100', '4000', '5000',
+ '6000', '7000', '8000', '9000', '9001')
+ GROUP BY DECODE(
+ f_invest_code,
+ '8000', 'INDIVIDUAL',
+ '9000', 'FOREIGN',
+ '9001', 'FOREIGN',
+ 'INSTITUTION')
+ )
+ ORDER BY DECODE(participant_code, 'INDIVIDUAL', 1, 'FOREIGN', 2, 3)
+ """);
+ }
+
+ public static DataQuerySpec S5085 { get; } = Oracle("""
+ WITH future_row AS (
+ SELECT DECODE(
+ SIGN(a.f_curr_price - a.f_base_price),
+ -1, (a.f_curr_price / 100) * -1,
+ a.f_curr_price / 100) futures_value
+ FROM t_sunmul_online a
+ JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code
+ WHERE a.f_stock_seq = 1
+ AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day)
+ AND b.f_month_gubun = '1'
+ FETCH FIRST 1 ROW ONLY
+ )
+ SELECT metric_code METRIC_CODE, metric_value METRIC_VALUE
+ FROM (
+ SELECT 1 sort_order, 'NET_BUY' metric_code,
+ ROUND((
+ (f_bcon_buy_w_amt + f_bcon_buy_j_amt) -
+ (f_bcon_sell_w_amt + f_bcon_sell_j_amt) +
+ (f_ccon_buy_w_amt + f_ccon_buy_j_amt) -
+ (f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000) metric_value
+ FROM t_pgm_tot
+ UNION ALL
+ SELECT 2, 'ARBITRAGE',
+ ROUND(((f_ccon_buy_w_amt + f_ccon_buy_j_amt) -
+ (f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000)
+ FROM t_pgm_tot
+ UNION ALL
+ SELECT 3, 'NON_ARBITRAGE',
+ ROUND(((f_bcon_buy_w_amt + f_bcon_buy_j_amt) -
+ (f_bcon_sell_w_amt + f_bcon_sell_j_amt)) / 100000000)
+ FROM t_pgm_tot
+ UNION ALL
+ SELECT 4, 'BASIS', futures_value - kospi200_value
+ FROM (
+ SELECT DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100) kospi200_value
+ FROM t_200_index
+ WHERE f_part_code = '029'
+ ), future_row
+ UNION ALL
+ SELECT 5, 'KOSPI200',
+ DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100)
+ FROM t_200_index
+ WHERE f_part_code = '029'
+ UNION ALL
+ SELECT 6, 'FUTURES', futures_value
+ FROM future_row
+ )
+ ORDER BY sort_order
+ """);
+
+ public static DataQuerySpec S6067 { get; } = Oracle("""
+ SELECT market_code MARKET_CODE, institution_code INSTITUTION_CODE, amount AMOUNT
+ FROM (
+ SELECT
+ 1 market_order,
+ DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5) institution_order,
+ 'KOSPI' market_code,
+ DECODE(
+ f_invest_code,
+ '1000', 'SECURITIES',
+ '2000', 'INSURANCE',
+ '3000', 'INVESTMENT_TRUST',
+ '4000', 'BANK',
+ '6000', 'FUND') institution_code,
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
+ FROM t_invest
+ WHERE f_part_code = '001'
+ AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
+ GROUP BY f_invest_code
+ UNION ALL
+ SELECT
+ 2,
+ DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5),
+ 'KOSDAQ',
+ DECODE(
+ f_invest_code,
+ '1000', 'SECURITIES',
+ '2000', 'INSURANCE',
+ '3000', 'INVESTMENT_TRUST',
+ '4000', 'BANK',
+ '6000', 'FUND'),
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000)
+ FROM t_kosdaq_invest
+ WHERE f_part_code = '001'
+ AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
+ GROUP BY f_invest_code
+ UNION ALL
+ SELECT
+ 3,
+ DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5),
+ 'KOSPI200',
+ DECODE(
+ f_invest_code,
+ '1000', 'SECURITIES',
+ '2000', 'INSURANCE',
+ '3000', 'INVESTMENT_TRUST',
+ '4000', 'BANK',
+ '6000', 'FUND'),
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000)
+ FROM t_invest
+ WHERE f_part_code = '029'
+ AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
+ GROUP BY f_invest_code
+ )
+ ORDER BY market_order, institution_order
+ """);
+
+ private static string CurrentInvestorFlow(string table) => $"""
+ SELECT
+ TO_CHAR((SELECT MAX(open_day) FROM v_open_day)) trading_date,
+ individual_amount,
+ foreign_amount,
+ institution_amount
+ FROM (
+ SELECT
+ SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount,
+ SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount,
+ SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount
+ FROM (
+ SELECT
+ f_invest_code,
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
+ FROM {table}
+ WHERE f_part_code = '001'
+ AND f_invest_code IN (
+ '1000', '2000', '3000', '3100', '4000', '5000',
+ '6000', '7000', '8000', '9000', '9001')
+ GROUP BY f_invest_code
+ )
+ )
+ """;
+
+ private static string HistoryInvestorFlow(string table, int exclusiveRowNumber) => $"""
+ SELECT
+ SUBSTR(f_data_time, 1, 8) trading_date,
+ SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount,
+ SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount,
+ SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount
+ FROM (
+ SELECT
+ f_data_time,
+ f_invest_code,
+ ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
+ FROM {table}
+ WHERE f_part_code = '001'
+ AND f_invest_code IN (
+ '1000', '2000', '3000', '3100', '4000', '5000',
+ '6000', '7000', '8000', '9000', '9001')
+ AND f_data_time IN (
+ SELECT data_time || '1535'
+ FROM (
+ SELECT open_day data_time FROM v_open_day
+ MINUS
+ SELECT SUBSTR(f_data_time, 1, 8) data_time
+ FROM t_index
+ WHERE f_part_code = '001'
+ ORDER BY data_time DESC
+ )
+ WHERE ROWNUM < {exclusiveRowNumber})
+ GROUP BY f_data_time, f_invest_code
+ )
+ GROUP BY SUBSTR(f_data_time, 1, 8)
+ """;
+
+ private static DataQuerySpec Oracle(string sql) =>
+ LegacyTabularSceneLoaderReader.Spec(DataSourceKind.Oracle, sql);
+}
+
+internal static class LegacyTabularSceneLoaderReader
+{
+ private const int MaximumTextLength = 256;
+ private const int MaximumSelectorLength = 128;
+
+ public static DataQuerySpec Spec(
+ DataSourceKind source,
+ string sql,
+ params DataQueryParameter[] parameters)
+ {
+ var spec = new DataQuerySpec(sql, parameters);
+ spec.ValidateFor(source);
+ return spec;
+ }
+
+ public static IReadOnlyList ExactRows(
+ DataTable? table,
+ int rowCount,
+ IReadOnlyList columns)
+ {
+ if (table is null || table.Rows.Count != rowCount || table.Columns.Count != columns.Count)
+ {
+ throw InvalidResult();
+ }
+
+ for (var index = 0; index < columns.Count; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ columns[index],
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw InvalidResult();
+ }
+ }
+
+ return table.Rows.Cast().ToArray();
+ }
+
+ public static string Text(DataRow row, string column, bool allowEmpty = false)
+ {
+ var text = Convert.ToString(Required(row, column), CultureInfo.InvariantCulture) ?? string.Empty;
+ if (text.Length > MaximumTextLength)
+ {
+ throw InvalidResult();
+ }
+
+ try
+ {
+ return LegacySceneBuilderGuard.Text(text, column, allowEmpty);
+ }
+ catch (LegacySceneDataException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static string Selector(string? value)
+ {
+ try
+ {
+ var selector = LegacySceneBuilderGuard.Text(value, "selector");
+ return selector.Length <= MaximumSelectorLength
+ ? selector
+ : throw InvalidRequest();
+ }
+ catch (LegacySceneDataException)
+ {
+ throw InvalidRequest();
+ }
+ }
+
+ public static string StockCode(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
+ value.Any(character => !char.IsAsciiLetterOrDigit(character)))
+ {
+ throw InvalidRequest();
+ }
+
+ return value;
+ }
+
+ public static DateOnly Date(DataRow row, string column)
+ {
+ var text = Text(row, column);
+ if (text.Length != 8 || text.Any(character => !char.IsAsciiDigit(character)) ||
+ !DateOnly.TryParseExact(
+ text,
+ "yyyyMMdd",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var value))
+ {
+ throw InvalidResult();
+ }
+
+ return value;
+ }
+
+ public static int Int32(DataRow row, string column)
+ {
+ var value = Decimal(row, column);
+ if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue)
+ {
+ throw InvalidResult();
+ }
+
+ return decimal.ToInt32(value);
+ }
+
+ public static long Int64(DataRow row, string column)
+ {
+ var value = Decimal(row, column);
+ if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue)
+ {
+ throw InvalidResult();
+ }
+
+ return decimal.ToInt64(value);
+ }
+
+ public static double Double(DataRow row, string column)
+ {
+ try
+ {
+ var value = Convert.ToDouble(Required(row, column), CultureInfo.InvariantCulture);
+ return double.IsFinite(value) ? value : throw InvalidResult();
+ }
+ catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ public static ScenePriceDirection Direction(DataRow row, string column) =>
+ Text(row, column) switch
+ {
+ "1" => ScenePriceDirection.LimitUp,
+ "2" => ScenePriceDirection.Up,
+ "3" => ScenePriceDirection.Flat,
+ "4" => ScenePriceDirection.LimitDown,
+ "5" => ScenePriceDirection.Down,
+ _ => throw InvalidResult()
+ };
+
+ public static TData Preflight(
+ ILegacySceneMutationBuilder builder,
+ TData data)
+ where TData : ILegacySceneData
+ {
+ _ = builder.Build(data);
+ return data;
+ }
+
+ public static LegacySceneDataException InvalidRequest() =>
+ new("The scene data request is invalid.");
+
+ public static LegacySceneDataException InvalidResult() =>
+ new("The scene query returned an invalid result.");
+
+ private static decimal Decimal(DataRow row, string column)
+ {
+ try
+ {
+ return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture);
+ }
+ catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidResult();
+ }
+ }
+
+ private static object Required(DataRow row, string column) =>
+ row[column] is { } value && value is not DBNull
+ ? value
+ : throw InvalidResult();
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs
new file mode 100644
index 0000000..bd6b317
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/GridSceneBuilder5082.cs
@@ -0,0 +1,169 @@
+#nullable enable
+
+using System.Globalization;
+using System.Data;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+public sealed record InvestorTradingValues(
+ int Individual,
+ int Foreign,
+ int Institution);
+
+public sealed record S5082SceneData(
+ InvestorTradingValues Kospi,
+ InvestorTradingValues Kosdaq,
+ InvestorTradingValues Kospi200) : ILegacySceneData;
+
+public sealed class S5082SceneMutationBuilder : ILegacySceneMutationBuilder
+{
+ public string BuilderKey => "s5082";
+
+ public IReadOnlyList Build(S5082SceneData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ InvestorTradingValues[] rows =
+ [
+ LegacySceneBuilderGuard.NotNull(data.Kospi, nameof(data.Kospi)),
+ LegacySceneBuilderGuard.NotNull(data.Kosdaq, nameof(data.Kosdaq)),
+ LegacySceneBuilderGuard.NotNull(data.Kospi200, nameof(data.Kospi200))
+ ];
+ var mutations = new List(54);
+ for (var rowIndex = 0; rowIndex < rows.Length; rowIndex++)
+ {
+ int[] values =
+ [
+ rows[rowIndex].Individual,
+ rows[rowIndex].Foreign,
+ rows[rowIndex].Institution
+ ];
+ for (var columnIndex = 0; columnIndex < values.Length; columnIndex++)
+ {
+ var text = values[columnIndex].ToString("#,##0", CultureInfo.InvariantCulture);
+ var prefix = $"row{rowIndex + 1}_value{columnIndex + 1}_";
+ for (var variant = 1; variant <= 3; variant++)
+ {
+ mutations.Add(new PlayoutSetValue(prefix + variant, text));
+ }
+
+ var visibleVariant = values[columnIndex] > 0
+ ? 1
+ : values[columnIndex] < 0
+ ? 3
+ : 2;
+ for (var variant = 1; variant <= 3; variant++)
+ {
+ mutations.Add(new PlayoutSetVisible(
+ prefix + variant,
+ variant == visibleVariant));
+ }
+ }
+ }
+
+ return mutations;
+ }
+}
+
+public sealed class S5082SceneDataLoader
+{
+ private const string Query = """
+ SELECT name,
+ SUM(DECODE(invest, '개인', org_amt, 0)) individual_value,
+ SUM(DECODE(invest, '외국인', org_amt, 0)) foreign_value,
+ SUM(DECODE(invest, '기관', org_amt, 0)) institution_value
+ FROM (
+ SELECT 1 s, '코스피' name,
+ DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest,
+ ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
+ FROM t_invest
+ WHERE f_part_code = '001'
+ AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
+ GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관')
+ UNION
+ SELECT 2 s, '코스닥' name,
+ DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest,
+ ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
+ FROM t_kosdaq_invest
+ WHERE f_part_code = '001'
+ AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
+ GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관')
+ UNION
+ SELECT 3 s, '코스피200' name,
+ DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관') invest,
+ ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
+ FROM t_invest
+ WHERE f_part_code = '029'
+ AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
+ GROUP BY DECODE(f_invest_code, '8000', '개인', '9000', '외국인', '9001', '외국인', '기관')
+ )
+ GROUP BY name, s
+ ORDER BY s
+ """;
+
+ private readonly IDataQueryExecutor _executor;
+
+ public S5082SceneDataLoader(IDataQueryExecutor executor)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ }
+
+ public async Task LoadAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var table = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ "SCENE_5082",
+ new DataQuerySpec(Query),
+ cancellationToken).ConfigureAwait(false);
+ var rows = MapRows(table);
+ return new S5082SceneData(rows["코스피"], rows["코스닥"], rows["코스피200"]);
+ }
+
+ private static IReadOnlyDictionary MapRows(DataTable? table)
+ {
+ string[] required = ["NAME", "INDIVIDUAL_VALUE", "FOREIGN_VALUE", "INSTITUTION_VALUE"];
+ if (table is null || table.Rows.Count != 3 || required.Any(name => !table.Columns.Contains(name)))
+ {
+ throw new LegacySceneDataException("Scene 5082 returned an unexpected result shape.");
+ }
+
+ var mapped = new Dictionary(StringComparer.Ordinal);
+ foreach (DataRow row in table.Rows)
+ {
+ var name = Convert.ToString(row["NAME"], CultureInfo.InvariantCulture) ?? string.Empty;
+ if (name is not ("코스피" or "코스닥" or "코스피200") ||
+ !mapped.TryAdd(name, new InvestorTradingValues(
+ RequiredInt(row, "INDIVIDUAL_VALUE"),
+ RequiredInt(row, "FOREIGN_VALUE"),
+ RequiredInt(row, "INSTITUTION_VALUE"))))
+ {
+ throw new LegacySceneDataException("Scene 5082 returned an unknown or duplicate market row.");
+ }
+ }
+
+ if (mapped.Count != 3)
+ {
+ throw new LegacySceneDataException("Scene 5082 did not return every required market row.");
+ }
+
+ return mapped;
+ }
+
+ private static int RequiredInt(DataRow row, string column)
+ {
+ if (row[column] is null or DBNull)
+ {
+ throw new LegacySceneDataException("Scene 5082 returned a required null value.");
+ }
+
+ try
+ {
+ return checked(Convert.ToInt32(row[column], CultureInfo.InvariantCulture));
+ }
+ catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw new LegacySceneDataException("Scene 5082 returned an invalid numeric value.");
+ }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs
new file mode 100644
index 0000000..ba6f6e9
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyGridMarketSceneRequestResolver.cs
@@ -0,0 +1,406 @@
+#nullable enable
+
+using System.Data;
+using System.IO;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Closed runtime plans for the legacy grid, trader, quote and trusted-manual cuts.
+/// They contain typed loader requests only; Web input can never supply SQL, mutations,
+/// COM operations or a manual-data path.
+///
+public abstract record LegacyGridMarketSceneLoadRequest(string BuilderKey);
+
+public sealed record LegacyS5023SceneLoadRequest(S5023SceneLoadRequest Request)
+ : LegacyGridMarketSceneLoadRequest("s5023");
+
+public sealed record LegacyS5024SceneLoadRequest(S5024SceneLoadRequest Request)
+ : LegacyGridMarketSceneLoadRequest("s5024");
+
+public sealed record LegacyS5025SceneLoadRequest(S5025ManualSceneLoadRequest Request)
+ : LegacyGridMarketSceneLoadRequest("s5025");
+
+public sealed record LegacyS5037SceneLoadRequest(S5037SceneLoadRequest Request)
+ : LegacyGridMarketSceneLoadRequest("s5037");
+
+public sealed record LegacyS5085SceneLoadRequest()
+ : LegacyGridMarketSceneLoadRequest("s5085");
+
+public sealed record LegacyS6067SceneLoadRequest()
+ : LegacyGridMarketSceneLoadRequest("s6067");
+
+public sealed record LegacyS8003SceneLoadRequest(S8003SceneLoadRequest Request)
+ : LegacyGridMarketSceneLoadRequest("s8003");
+
+///
+/// Mirrors the original MainForm mapping of playlist code/jongmok/forCutInfo/sub to
+/// , ,
+/// and
+/// . Stock-master resolution remains read-only
+/// and parameterized. The optional hidden is
+/// only an integrity check and is never used as SQL or as a path.
+///
+public sealed class LegacyGridMarketSceneRequestResolver
+{
+ private static readonly string[] StockCodeColumns = ["STOCK_CODE"];
+
+ private readonly IDataQueryExecutor _executor;
+ private readonly IS5025TrustedManualDataSource? _trustedManualSource;
+
+ public LegacyGridMarketSceneRequestResolver(
+ IDataQueryExecutor executor,
+ IS5025TrustedManualDataSource? trustedManualSource = null)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ _trustedManualSource = trustedManualSource;
+ }
+
+ public async Task ResolveAsync(
+ LegacyPlaylistEntry entry,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ return entry.CutCode switch
+ {
+ "5023" => new LegacyS5023SceneLoadRequest(CreateS5023(entry)),
+ "5024" => new LegacyS5024SceneLoadRequest(CreateS5024(entry)),
+ "5025" => new LegacyS5025SceneLoadRequest(CreateS5025(entry)),
+ "5037" => new LegacyS5037SceneLoadRequest(CreateS5037(entry)),
+ "5085" => CreateS5085(entry),
+ "6067" => CreateS6067(entry),
+ "8003" => new LegacyS8003SceneLoadRequest(
+ await ResolveS8003Async(entry, cancellationToken).ConfigureAwait(false)),
+ _ => throw new LegacySceneDataException(
+ "The selected cut is not supported by the grid-market resolver.")
+ };
+ }
+
+ ///
+ /// Required runtime path for s5025. The trusted source is supplied by native
+ /// composition/configuration, never by LegacySceneSelection. Reading and validating
+ /// all five rows completes before any cue reaches DB or COM execution.
+ ///
+ public async Task LoadTrustedS5025Async(
+ LegacyPlaylistEntry entry,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ var request = CreateS5025(entry);
+ var source = _trustedManualSource ?? throw new LegacySceneDataException(
+ "Trusted manual scene data is not configured.");
+ try
+ {
+ return await new S5025SceneDataLoader(source)
+ .LoadAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (LegacySceneDataException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is IOException or
+ UnauthorizedAccessException or
+ InvalidOperationException)
+ {
+ throw new LegacySceneDataException("Trusted manual scene data is unavailable.");
+ }
+ }
+
+ private static S5023SceneLoadRequest CreateS5023(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireIndexSubject(selection.Subject);
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
+ RequireNoDataCode(selection.DataCode);
+ var market = ParseTradingTrendMarket(selection.GraphicType);
+ RequireSameMarket(selection.GroupCode, market);
+ return new S5023SceneLoadRequest(market);
+ }
+
+ private static S5024SceneLoadRequest CreateS5024(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireIndexSubject(selection.Subject);
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Bar);
+ RequireNoDataCode(selection.DataCode);
+ var market = ParseTradingTrendMarket(selection.GraphicType);
+ RequireSameMarket(selection.GroupCode, market);
+ return new S5024SceneLoadRequest(market);
+ }
+
+ private S5025ManualSceneLoadRequest CreateS5025(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireIndexSubject(selection.Subject);
+ RequireManualNetSellGraphic(selection.GraphicType);
+ RequireManualNetSellGraphic(selection.Subtype);
+ RequireNoDataCode(selection.DataCode);
+ if (_trustedManualSource is null)
+ {
+ throw new LegacySceneDataException("Trusted manual scene data is not configured.");
+ }
+
+ var audience = RequireText(selection.GroupCode) switch
+ {
+ "개인 순매도 상위(수동)" or "INDIVIDUAL" => S5025ManualAudience.Individual,
+ "외국인 순매도 상위(수동)" or "FOREIGN" => S5025ManualAudience.Foreign,
+ "기관 순매도 상위(수동)" or "INSTITUTION" => S5025ManualAudience.Institution,
+ _ => throw InvalidSelection()
+ };
+ return new S5025ManualSceneLoadRequest(audience);
+ }
+
+ private static S5037SceneLoadRequest CreateS5037(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireGraphic(selection.GraphicType, "거래원", "TRADER");
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
+ RequireOptionalStockCode(selection.DataCode);
+ return new S5037SceneLoadRequest(
+ ParseMarket(selection.GroupCode),
+ RequireSelector(selection.Subject));
+ }
+
+ private static LegacyS5085SceneLoadRequest CreateS5085(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireAllGroup(selection.GroupCode);
+ RequireIndexSubject(selection.Subject);
+ RequireGraphic(selection.GraphicType, "프로그램 매매", "PROGRAM_TRADING");
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
+ RequireNoDataCode(selection.DataCode);
+ return new LegacyS5085SceneLoadRequest();
+ }
+
+ private static LegacyS6067SceneLoadRequest CreateS6067(LegacyPlaylistEntry entry)
+ {
+ var selection = RequireSelection(entry);
+ RequireAllGroup(selection.GroupCode);
+ RequireIndexSubject(selection.Subject);
+ RequireGraphic(selection.GraphicType, "기관 순매수 현황", "INSTITUTION_NET_BUY");
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
+ RequireNoDataCode(selection.DataCode);
+ return new LegacyS6067SceneLoadRequest();
+ }
+
+ private async Task ResolveS8003Async(
+ LegacyPlaylistEntry entry,
+ CancellationToken cancellationToken)
+ {
+ var selection = RequireSelection(entry);
+ var expectedMarket = ParseMarket(selection.GroupCode);
+ var stockName = RequireSelector(selection.Subject);
+ RequireGraphic(selection.GraphicType, "호가창", "ORDER_BOOK");
+ RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
+ var expectedCode = RequireOptionalStockCode(selection.DataCode);
+
+ var kospi = await ResolveStockAsync(
+ LegacyDomesticMarket.Kospi,
+ stockName,
+ cancellationToken).ConfigureAwait(false);
+ var kosdaq = await ResolveStockAsync(
+ LegacyDomesticMarket.Kosdaq,
+ stockName,
+ cancellationToken).ConfigureAwait(false);
+
+ // MainForm iterated KOSPI first and KOSDAQ second, so the latter won when
+ // an identical display name existed in both cached stock-master tables.
+ var resolved = kosdaq ?? kospi ?? throw new LegacySceneDataException(
+ "The order-book stock selection was not found.");
+ if (resolved.Market != expectedMarket ||
+ (expectedCode.Length > 0 &&
+ !string.Equals(expectedCode, resolved.StockCode, StringComparison.Ordinal)))
+ {
+ throw InvalidSelection();
+ }
+
+ return new S8003SceneLoadRequest(resolved.Market, resolved.StockCode);
+ }
+
+ private async Task ResolveStockAsync(
+ LegacyDomesticMarket market,
+ string stockName,
+ CancellationToken cancellationToken)
+ {
+ var (tableName, table) = market switch
+ {
+ LegacyDomesticMarket.Kospi => ("SCENE_RESOLVE_8003_KOSPI", "T_STOCK"),
+ LegacyDomesticMarket.Kosdaq => ("SCENE_RESOLVE_8003_KOSDAQ", "T_KOSDAQ_STOCK"),
+ _ => throw InvalidSelection()
+ };
+ var spec = LegacyTabularSceneLoaderReader.Spec(
+ DataSourceKind.Oracle,
+ $"""
+ SELECT f_stock_code STOCK_CODE
+ FROM {table}
+ WHERE f_mkt_halt = 'N'
+ AND f_stock_wanname = :stockName
+ """,
+ new DataQueryParameter("stockName", stockName, DbType.String));
+ var result = await _executor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ tableName,
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ if (result is null || result.Columns.Count != StockCodeColumns.Length ||
+ !string.Equals(
+ result.Columns[0].ColumnName,
+ StockCodeColumns[0],
+ StringComparison.OrdinalIgnoreCase) ||
+ result.Rows.Count > 1)
+ {
+ throw new LegacySceneDataException(
+ "An order-book stock-master lookup returned an invalid result.");
+ }
+
+ if (result.Rows.Count == 0)
+ {
+ return null;
+ }
+
+ if (result.Rows[0][0] is null or DBNull)
+ {
+ throw new LegacySceneDataException(
+ "An order-book stock-master lookup returned an invalid result.");
+ }
+
+ var code = RequireStockCode(
+ Convert.ToString(result.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture));
+ return new ResolvedStock(market, code);
+ }
+
+ private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
+ entry.Selection ?? throw new LegacySceneDataException(
+ "The selected cut requires native lookup information.");
+
+ private static LegacyDomesticMarket ParseTradingTrendMarket(string value) =>
+ RequireText(value) switch
+ {
+ "코스피 매매동향" or "KOSPI_TRADING_TREND" => LegacyDomesticMarket.Kospi,
+ "코스닥 매매동향" or "KOSDAQ_TRADING_TREND" => LegacyDomesticMarket.Kosdaq,
+ _ => throw InvalidSelection()
+ };
+
+ private static LegacyDomesticMarket ParseMarket(string value) =>
+ RequireText(value) switch
+ {
+ "코스피" or "KOSPI" => LegacyDomesticMarket.Kospi,
+ "코스닥" or "KOSDAQ" => LegacyDomesticMarket.Kosdaq,
+ _ => throw InvalidSelection()
+ };
+
+ private static void RequireSameMarket(string value, LegacyDomesticMarket expected)
+ {
+ if (ParseMarket(value) != expected)
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireIndexSubject(string value)
+ {
+ if (RequireText(value) is not ("지수" or "INDEX"))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireAllGroup(string value)
+ {
+ if (RequireText(value) is not ("전체" or "ALL"))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireSubtype(string value, SceneGraphicSubtype subtype)
+ {
+ var text = RequireText(value);
+ var valid = subtype switch
+ {
+ SceneGraphicSubtype.Table => text is "표그래프" or "TABLE_GRAPH",
+ SceneGraphicSubtype.Bar => text is "막대그래프" or "BAR_GRAPH",
+ _ => false
+ };
+ if (!valid)
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireManualNetSellGraphic(string value)
+ {
+ if (RequireText(value) is not ("순매도 상위" or "MANUAL_NET_SELL"))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static void RequireGraphic(string value, string korean, string stableCode)
+ {
+ var text = RequireText(value);
+ if (!string.Equals(text, korean, StringComparison.Ordinal) &&
+ !string.Equals(text, stableCode, StringComparison.Ordinal))
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static string RequireSelector(string value)
+ {
+ var text = RequireText(value);
+ if (text.Length > 128)
+ {
+ throw InvalidSelection();
+ }
+
+ return text;
+ }
+
+ private static string RequireText(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 256 || value.Any(char.IsControl))
+ {
+ throw InvalidSelection();
+ }
+
+ return value.Trim();
+ }
+
+ private static void RequireNoDataCode(string? value)
+ {
+ if (value is null || value.Length != 0)
+ {
+ throw InvalidSelection();
+ }
+ }
+
+ private static string RequireOptionalStockCode(string? value) =>
+ value is { Length: 0 } ? string.Empty : RequireStockCode(value);
+
+ private static string RequireStockCode(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
+ value.Any(character => !char.IsAsciiLetterOrDigit(character)))
+ {
+ throw InvalidSelection();
+ }
+
+ return value;
+ }
+
+ private static LegacySceneDataException InvalidSelection() =>
+ new("The selected scene lookup fields are invalid.");
+
+ private sealed record ResolvedStock(
+ LegacyDomesticMarket Market,
+ string StockCode);
+
+ private enum SceneGraphicSubtype
+ {
+ Table,
+ Bar
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs
new file mode 100644
index 0000000..bb7067d
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs
@@ -0,0 +1,639 @@
+#nullable enable
+
+using System.Data;
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Closed result for the legacy aliases (8018/8032/5032) that MainForm routed
+/// to one of two different scene builders after inspecting the playlist fields.
+///
+public abstract record LegacyTwoColumnSceneLoadRequest(string BuilderKey);
+
+public sealed record LegacyS5032LoadRequest(S5032SceneLoadRequest Request)
+ : LegacyTwoColumnSceneLoadRequest("s5032");
+
+public sealed record LegacyS8018LoadRequest(S8018SceneLoadRequest Request)
+ : LegacyTwoColumnSceneLoadRequest("s8018");
+
+///
+/// Converts the five legacy playlist columns into closed, scene-specific load requests.
+/// The resolver mirrors MainForm's conditional s5001/s5032/s8018/s8001 dispatch and uses
+/// parameterized, read-only stock-master lookups where MainForm used its cached stock tables.
+///
+public sealed class LegacyParameterizedSceneRequestResolver
+{
+ private readonly IDataQueryExecutor _executor;
+ private readonly TimeProvider _timeProvider;
+
+ public LegacyParameterizedSceneRequestResolver(
+ IDataQueryExecutor executor,
+ TimeProvider? timeProvider = null)
+ {
+ _executor = executor ?? throw new ArgumentNullException(nameof(executor));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public S5001SceneLoadRequest CreateS5001Request(LegacyPlaylistEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.CutCode is not ("5001" or "N5001"))
+ {
+ throw new LegacySceneDataException("The selected cut is not an s5001 alias.");
+ }
+
+ var selection = RequireSelection(entry);
+ var subject = RequireSelector(selection.Subject, "subject");
+
+ if (subject == "BDI 지수" || subject == "BALTIC_DRY_INDEX")
+ {
+ return new S5001BalticDryIndexLoadRequest();
+ }
+
+ if (TryParseCommodity(subject, out var commodity))
+ {
+ return new S5001CommodityLoadRequest(commodity);
+ }
+
+ if (entry.CutCode == "N5001")
+ {
+ return CreateS5001DomesticStock(selection, subject);
+ }
+
+ if (subject is "지수" or "INDEX")
+ {
+ return new S5001DomesticIndexLoadRequest(
+ ParseDomesticIndex(selection.GroupCode),
+ IsExpectedIndex(selection.Subtype)
+ ? S5001IndexMode.Expected
+ : S5001IndexMode.Current);
+ }
+
+ if (selection.GroupCode is "해외지수" or "FOREIGN_INDEX")
+ {
+ return new S5001ForeignIndexLoadRequest(ParseForeignIndex(subject));
+ }
+
+ if (selection.GroupCode is "환율" or "EXCHANGE_RATE")
+ {
+ return new S5001ExchangeRateLoadRequest(ParseExchangeRate(subject));
+ }
+
+ if (selection.GroupCode is "해외업종" or "FOREIGN_INDUSTRY")
+ {
+ return new S5001ForeignIndustryLoadRequest(subject);
+ }
+
+ if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK")
+ {
+ return new S5001ForeignStockLoadRequest(subject);
+ }
+
+ if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) ||
+ selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal))
+ {
+ return new S5001DomesticIndustryLoadRequest(
+ ParseDomesticMarket(selection.GroupCode, allowNxt: true),
+ subject);
+ }
+
+ return CreateS5001DomesticStock(selection, subject);
+ }
+
+ public async Task ResolveTwoColumnRequestAsync(
+ LegacyPlaylistEntry entry,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.CutCode is not ("8018" or "8032" or "5032"))
+ {
+ throw new LegacySceneDataException("The selected cut is not a two-column alias.");
+ }
+
+ var selection = RequireSelection(entry);
+ var subject = RequireSelector(selection.Subject, "subject");
+
+ // MainForm selects s8018 idx=3 before considering the futures condition.
+ if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) ||
+ selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal))
+ {
+ var industries = SplitPair(subject, '-');
+ var market = ParseDomesticMarket(selection.GroupCode, allowNxt: false);
+ return new LegacyS8018LoadRequest(
+ new S8018DomesticIndustryPairLoadRequest(
+ market,
+ industries[0],
+ industries[1]));
+ }
+
+ // MainForm selects the special s5032 builder whenever the combined subject
+ // contains the Korean futures label. s5032 then normalizes futures to side two.
+ if (subject.Contains("선물", StringComparison.Ordinal) ||
+ subject.Contains("FUTURES", StringComparison.Ordinal))
+ {
+ var values = SplitPair(subject, ',');
+ var first = ParseMarketTarget(values[0]);
+ var second = ParseMarketTarget(values[1]);
+ var firstIsFutures = first == LegacyMarketQuoteTarget.Futures;
+ var secondIsFutures = second == LegacyMarketQuoteTarget.Futures;
+ if (firstIsFutures == secondIsFutures)
+ {
+ throw new LegacySceneDataException(
+ "The s5032 selection must contain exactly one futures item.");
+ }
+
+ return new LegacyS5032LoadRequest(
+ new S5032IndexAndFuturesLoadRequest(firstIsFutures ? second : first));
+ }
+
+ var pair = SplitPair(subject, ',');
+ var expected = IsExpectedPair(selection.Subtype);
+ var firstIsTarget = TryParseMarketTarget(pair[0], out var firstTarget);
+ var secondIsTarget = TryParseMarketTarget(pair[1], out var secondTarget);
+
+ if (firstIsTarget && secondIsTarget)
+ {
+ return new LegacyS8018LoadRequest(
+ new S8018MarketPairLoadRequest(
+ expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current,
+ firstTarget,
+ secondTarget));
+ }
+
+ if (firstIsTarget != secondIsTarget)
+ {
+ var stockIndex = firstIsTarget ? 1 : 0;
+ var stock = await ResolveCurrentStockAsync(
+ pair[stockIndex],
+ allowWorld: false,
+ cancellationToken).ConfigureAwait(false);
+ var domesticMarket = ToKrxMarket(stock.Market);
+ return new LegacyS8018LoadRequest(
+ new S8018MixedMarketAndStockLoadRequest(
+ stockIndex == 0 ? S8018PairSide.First : S8018PairSide.Second,
+ firstIsTarget ? firstTarget : secondTarget,
+ domesticMarket,
+ stock.StockName,
+ expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current));
+ }
+
+ if (expected)
+ {
+ var first = await ResolveDomesticStockAsync(pair[0], cancellationToken)
+ .ConfigureAwait(false);
+ var second = await ResolveDomesticStockAsync(pair[1], cancellationToken)
+ .ConfigureAwait(false);
+ return new LegacyS8018LoadRequest(
+ new S8018ExpectedStockPairLoadRequest(
+ first.Market,
+ first.StockName,
+ second.Market,
+ second.StockName));
+ }
+
+ var currentFirst = await ResolveCurrentStockAsync(
+ pair[0],
+ allowWorld: true,
+ cancellationToken).ConfigureAwait(false);
+ var currentSecond = await ResolveCurrentStockAsync(
+ pair[1],
+ allowWorld: true,
+ cancellationToken).ConfigureAwait(false);
+ return new LegacyS8018LoadRequest(
+ new S8018CurrentStockPairLoadRequest(
+ CurrentSession(),
+ currentFirst,
+ currentSecond));
+ }
+
+ public S8001SceneLoadRequest CreateS8001Request(LegacyPlaylistEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.CutCode is not ("8001" or "8002"))
+ {
+ throw new LegacySceneDataException("The selected cut is not an s8001 alias.");
+ }
+
+ var subject = RequireSelector(RequireSelection(entry).Subject, "subject");
+ return subject switch
+ {
+ "코스피" or "KOSPI" => new S8001SceneLoadRequest(S8001Market.Kospi),
+ "코스닥" or "KOSDAQ" => new S8001SceneLoadRequest(S8001Market.Kosdaq),
+ _ => throw new LegacySceneDataException("The s8001 market selection is invalid.")
+ };
+ }
+
+ private S5001DomesticStockLoadRequest CreateS5001DomesticStock(
+ LegacySceneSelection selection,
+ string subject)
+ {
+ var market = ParseDomesticMarket(selection.GroupCode, allowNxt: true);
+ var mode = selection.Subtype switch
+ {
+ "예상체결가" or "EXPECTED" or "EXPECTED_OPENING" =>
+ S5001DomesticStockMode.ExpectedOpening,
+ "시간외단일가" or "AFTER_HOURS" or "AFTER_HOURS_SINGLE_PRICE" =>
+ S5001DomesticStockMode.AfterHoursSinglePrice,
+ _ => S5001DomesticStockMode.Current
+ };
+ var halted = mode == S5001DomesticStockMode.Current &&
+ (selection.Subtype.Contains("거래정지", StringComparison.Ordinal) ||
+ selection.Subtype == "HALTED");
+ var nxt = market is LegacyDomesticEquityMarket.NxtKospi or
+ LegacyDomesticEquityMarket.NxtKosdaq;
+ var badge = !nxt
+ ? S5001NxtBadge.None
+ : _timeProvider.GetLocalNow().Hour <= 12
+ ? S5001NxtBadge.PreMarket
+ : S5001NxtBadge.AfterMarket;
+ return new S5001DomesticStockLoadRequest(
+ market,
+ mode,
+ badge,
+ halted ? S5001StockTradingStatus.Halted : S5001StockTradingStatus.Regular,
+ subject);
+ }
+
+ private async Task ResolveCurrentStockAsync(
+ string rawName,
+ bool allowWorld,
+ CancellationToken cancellationToken)
+ {
+ var name = RequireSelector(rawName, "stock");
+ var isNxt = name.Contains("(NXT)", StringComparison.Ordinal);
+ var selector = isNxt
+ ? RequireSelector(name.Replace("(NXT)", string.Empty, StringComparison.Ordinal), "stock")
+ : name;
+
+ if (isNxt)
+ {
+ S8018StockMarket? market = null;
+ if (await StockExistsAsync(StockMasterKind.NxtKospi, selector, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ market = S8018StockMarket.NxtKospi;
+ }
+
+ // Preserve MainForm's later-table precedence when names overlap.
+ if (await StockExistsAsync(StockMasterKind.NxtKosdaq, selector, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ market = S8018StockMarket.NxtKosdaq;
+ }
+
+ return market.HasValue
+ ? new S8018CurrentStockSelection(market.Value, selector)
+ : throw new LegacySceneDataException("The NXT stock selection was not found.");
+ }
+
+ S8018StockMarket? domestic = null;
+ if (await StockExistsAsync(StockMasterKind.Kospi, selector, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ domestic = S8018StockMarket.Kospi;
+ }
+
+ if (await StockExistsAsync(StockMasterKind.Kosdaq, selector, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ domestic = S8018StockMarket.Kosdaq;
+ }
+
+ if (domestic.HasValue)
+ {
+ return new S8018CurrentStockSelection(domestic.Value, selector);
+ }
+
+ if (allowWorld &&
+ await StockExistsAsync(StockMasterKind.World, selector, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ return new S8018CurrentStockSelection(S8018StockMarket.World, selector);
+ }
+
+ throw new LegacySceneDataException("The stock selection was not found.");
+ }
+
+ private async Task ResolveDomesticStockAsync(
+ string rawName,
+ CancellationToken cancellationToken)
+ {
+ if (rawName.Contains("(NXT)", StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException(
+ "Expected stock data is unavailable for NXT selections.");
+ }
+
+ var current = await ResolveCurrentStockAsync(
+ rawName,
+ allowWorld: false,
+ cancellationToken).ConfigureAwait(false);
+ return new DomesticStockSelection(ToKrxMarket(current.Market), current.StockName);
+ }
+
+ private async Task StockExistsAsync(
+ StockMasterKind kind,
+ string stockName,
+ CancellationToken cancellationToken)
+ {
+ var query = StockMasterQuery(kind, stockName);
+ var table = await _executor.ExecuteAsync(
+ query.Source,
+ query.TableName,
+ query.Spec,
+ cancellationToken).ConfigureAwait(false);
+ if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 ||
+ !string.Equals(table.Columns[0].ColumnName, "MATCH_COUNT", StringComparison.OrdinalIgnoreCase) ||
+ table.Rows[0][0] is null or DBNull)
+ {
+ throw new LegacySceneDataException("A stock-master lookup returned an invalid schema.");
+ }
+
+ try
+ {
+ var count = Convert.ToInt32(table.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture);
+ return count switch
+ {
+ 0 => false,
+ 1 => true,
+ _ => throw new LegacySceneDataException(
+ "A stock-master lookup returned a non-unique selection.")
+ };
+ }
+ catch (LegacySceneDataException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
+ {
+ _ = exception;
+ throw new LegacySceneDataException(
+ "A stock-master lookup returned an invalid count.");
+ }
+ }
+
+ private static ParameterizedSceneQuery StockMasterQuery(
+ StockMasterKind kind,
+ string stockName)
+ {
+ var parameter = new DataQueryParameter("stockName", stockName, DbType.String);
+ return kind switch
+ {
+ StockMasterKind.Kospi => new ParameterizedSceneQuery(
+ DataSourceKind.Oracle,
+ "SCENE_RESOLVE_KOSPI_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " +
+ "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
+ [parameter])),
+ StockMasterKind.Kosdaq => new ParameterizedSceneQuery(
+ DataSourceKind.Oracle,
+ "SCENE_RESOLVE_KOSDAQ_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " +
+ "WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
+ [parameter])),
+ StockMasterKind.NxtKospi => new ParameterizedSceneQuery(
+ DataSourceKind.MariaDb,
+ "SCENE_RESOLVE_NXT_KOSPI_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM N_STOCK " +
+ "WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName",
+ [parameter])),
+ StockMasterKind.NxtKosdaq => new ParameterizedSceneQuery(
+ DataSourceKind.MariaDb,
+ "SCENE_RESOLVE_NXT_KOSDAQ_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM N_KOSDAQ_STOCK " +
+ "WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName",
+ [parameter])),
+ StockMasterKind.World => new ParameterizedSceneQuery(
+ DataSourceKind.Oracle,
+ "SCENE_RESOLVE_WORLD_STOCK",
+ new DataQuerySpec(
+ "SELECT COUNT(*) MATCH_COUNT FROM T_WORLD_IX_EQ_MASTER " +
+ "WHERE F_KNAM = :stockName",
+ [parameter])),
+ _ => throw new LegacySceneDataException("The stock-master lookup is invalid.")
+ };
+ }
+
+ private S8018SessionPhase CurrentSession()
+ {
+ var now = _timeProvider.GetLocalNow();
+ return now.TimeOfDay > new TimeSpan(15, 30, 0)
+ ? S8018SessionPhase.AfterKrxClose
+ : S8018SessionPhase.BeforeOrAtKrxClose;
+ }
+
+ private static LegacyDomesticEquityMarket ToKrxMarket(S8018StockMarket market) => market switch
+ {
+ S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi,
+ S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq,
+ _ => throw new LegacySceneDataException(
+ "The selected scene branch supports only KRX stocks.")
+ };
+
+ private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
+ entry.Selection ?? throw new LegacySceneDataException(
+ "The selected cut requires native database lookup information.");
+
+ private static string RequireSelector(string value, string label)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new LegacySceneDataException($"The scene {label} selection is required.");
+ }
+
+ return value.Trim();
+ }
+
+ private static string[] SplitPair(string value, char separator)
+ {
+ var values = value.Split(separator, StringSplitOptions.TrimEntries);
+ if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new LegacySceneDataException("The scene pair selection is invalid.");
+ }
+
+ return values;
+ }
+
+ private static LegacyDomesticEquityMarket ParseDomesticMarket(string value, bool allowNxt)
+ {
+ var market = value switch
+ {
+ "코스피" or "KOSPI" or "KOSPI_STOCK" => LegacyDomesticEquityMarket.Kospi,
+ "코스닥" or "KOSDAQ" or "KOSDAQ_STOCK" => LegacyDomesticEquityMarket.Kosdaq,
+ "코스피_NXT" or "NXT코스피" or "NXT_KOSPI" => LegacyDomesticEquityMarket.NxtKospi,
+ "코스닥_NXT" or "NXT코스닥" or "NXT_KOSDAQ" => LegacyDomesticEquityMarket.NxtKosdaq,
+ _ when value.Contains("코스피_NXT", StringComparison.Ordinal) ||
+ value.Contains("NXT_KOSPI", StringComparison.Ordinal) =>
+ LegacyDomesticEquityMarket.NxtKospi,
+ _ when value.Contains("코스닥_NXT", StringComparison.Ordinal) ||
+ value.Contains("NXT_KOSDAQ", StringComparison.Ordinal) =>
+ LegacyDomesticEquityMarket.NxtKosdaq,
+ _ when value.Contains("코스피", StringComparison.Ordinal) ||
+ value.Contains("KOSPI", StringComparison.Ordinal) =>
+ LegacyDomesticEquityMarket.Kospi,
+ _ when value.Contains("코스닥", StringComparison.Ordinal) ||
+ value.Contains("KOSDAQ", StringComparison.Ordinal) =>
+ LegacyDomesticEquityMarket.Kosdaq,
+ _ => throw new LegacySceneDataException("The domestic equity market selection is invalid.")
+ };
+
+ if (!allowNxt && market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq)
+ {
+ throw new LegacySceneDataException("The selected scene branch does not support NXT.");
+ }
+
+ return market;
+ }
+
+ private static S5001DomesticIndexTarget ParseDomesticIndex(string value) => value switch
+ {
+ "코스피" or "KOSPI" => S5001DomesticIndexTarget.Kospi,
+ "코스닥" or "KOSDAQ" => S5001DomesticIndexTarget.Kosdaq,
+ "코스피200" or "코스피 200" or "KOSPI200" => S5001DomesticIndexTarget.Kospi200,
+ "선물" or "FUTURES" => S5001DomesticIndexTarget.Futures,
+ "KRX100" or "KRX 100" => S5001DomesticIndexTarget.Krx100,
+ _ => throw new LegacySceneDataException("The domestic index selection is invalid.")
+ };
+
+ private static bool IsExpectedIndex(string value) => value is
+ "예상지수" or "EXPECTED" or "EXPECTED_INDEX";
+
+ private static bool IsExpectedPair(string value) => value is
+ "예상체결" or "예상체결가" or "EXPECTED" or "EXPECTED_OPENING";
+
+ private static S5001ForeignIndexTarget ParseForeignIndex(string value) => value switch
+ {
+ "다우" or "Dow" => S5001ForeignIndexTarget.Dow,
+ "나스닥" or "Nasdaq" => S5001ForeignIndexTarget.Nasdaq,
+ "S&P" or "S&P500" or "Sp500" => S5001ForeignIndexTarget.Sp500,
+ "독일" or "GermanyDax" => S5001ForeignIndexTarget.GermanyDax,
+ "영국" or "UnitedKingdomFtse" => S5001ForeignIndexTarget.UnitedKingdomFtse,
+ "프랑스" or "FranceCac" => S5001ForeignIndexTarget.FranceCac,
+ "니케이" or "Nikkei" => S5001ForeignIndexTarget.Nikkei,
+ "중국 상해" or "ShanghaiComposite" => S5001ForeignIndexTarget.ShanghaiComposite,
+ "홍콩 항셍" or "HangSeng" => S5001ForeignIndexTarget.HangSeng,
+ "대만 가권" or "TaiwanWeighted" => S5001ForeignIndexTarget.TaiwanWeighted,
+ "싱가포르 지수" or "SingaporeStraitsTimes" => S5001ForeignIndexTarget.SingaporeStraitsTimes,
+ "태국 지수" or "ThailandSet" => S5001ForeignIndexTarget.ThailandSet,
+ "필리핀 지수" or "PhilippinesComposite" => S5001ForeignIndexTarget.PhilippinesComposite,
+ "말레이시아 지수" or "MalaysiaKlse" => S5001ForeignIndexTarget.MalaysiaKlse,
+ "인도네시아 지수" or "IndonesiaComposite" => S5001ForeignIndexTarget.IndonesiaComposite,
+ _ => throw new LegacySceneDataException("The foreign index selection is invalid.")
+ };
+
+ private static S5001ExchangeRateTarget ParseExchangeRate(string value) => value switch
+ {
+ "원달러" or "WonDollar" => S5001ExchangeRateTarget.WonDollar,
+ "원엔" or "WonYen" => S5001ExchangeRateTarget.WonYen,
+ "원위엔" or "원위안" or "WonYuan" => S5001ExchangeRateTarget.WonYuan,
+ "원유로" or "WonEuro" => S5001ExchangeRateTarget.WonEuro,
+ _ => throw new LegacySceneDataException("The exchange-rate selection is invalid.")
+ };
+
+ private static LegacyMarketQuoteTarget ParseMarketTarget(string value) =>
+ TryParseMarketTarget(value, out var target)
+ ? target
+ : throw new LegacySceneDataException("The market item selection is invalid.");
+
+ private static bool TryParseMarketTarget(string value, out LegacyMarketQuoteTarget target)
+ {
+ var text = value.Trim();
+ if (Enum.TryParse(text, ignoreCase: false, out target) &&
+ Enum.IsDefined(target))
+ {
+ return true;
+ }
+
+ if (text.Contains("코스피", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kospi;
+ else if (text.Contains("코스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kosdaq;
+ else if (text.Contains("선물", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Futures;
+ else if (text.Contains("KRX100", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Krx100;
+ else if (text.Contains("원달러", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonDollar;
+ else if (text.Contains("원엔", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYen;
+ else if (text.Contains("원위엔", StringComparison.Ordinal) || text.Contains("원위안", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYuan;
+ else if (text.Contains("원유로", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonEuro;
+ else if (text.Contains("다우", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Dow;
+ else if (text.Contains("나스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nasdaq;
+ else if (text.Contains("S&P", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Sp500;
+ else if (text.Contains("독일", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.GermanyDax;
+ else if (text.Contains("영국", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.UnitedKingdomFtse;
+ else if (text.Contains("프랑스", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.FranceCac;
+ else if (text.Contains("니케이", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nikkei;
+ else if (text.Contains("중국 상해", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ShanghaiComposite;
+ else if (text.Contains("홍콩 항셍", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.HangSeng;
+ else if (text.Contains("대만 가권", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.TaiwanWeighted;
+ else if (text.Contains("싱가포르 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.SingaporeStraitsTimes;
+ else if (text.Contains("태국 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ThailandSet;
+ else if (text.Contains("필리핀 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.PhilippinesComposite;
+ else if (text.Contains("말레이시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.MalaysiaKlse;
+ else if (text.Contains("인도네시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.IndonesiaComposite;
+ else
+ {
+ target = default;
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool TryParseCommodity(string value, out S5001CommodityTarget target)
+ {
+ if (Enum.TryParse(value, ignoreCase: false, out target) && Enum.IsDefined(target))
+ {
+ return true;
+ }
+
+ return CommodityNames.TryGetValue(value, out target);
+ }
+
+ private static readonly IReadOnlyDictionary CommodityNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["소맥"] = S5001CommodityTarget.FeedWheat,
+ ["옥수수"] = S5001CommodityTarget.Corn,
+ ["밀"] = S5001CommodityTarget.Wheat,
+ ["대두"] = S5001CommodityTarget.Soybean,
+ ["콩"] = S5001CommodityTarget.Bean,
+ ["현미"] = S5001CommodityTarget.BrownRice,
+ ["커피"] = S5001CommodityTarget.Coffee,
+ ["코코아"] = S5001CommodityTarget.Cocoa,
+ ["설탕"] = S5001CommodityTarget.Sugar,
+ ["원면"] = S5001CommodityTarget.RawCotton,
+ ["목화(면화)"] = S5001CommodityTarget.Cotton,
+ ["생우(소)"] = S5001CommodityTarget.LiveCattle,
+ ["비육우"] = S5001CommodityTarget.FeederCattle,
+ ["돈육(돼지)"] = S5001CommodityTarget.LeanHogs,
+ ["구리"] = S5001CommodityTarget.Copper,
+ ["철광석"] = S5001CommodityTarget.IronOre,
+ ["니켈"] = S5001CommodityTarget.Nickel,
+ ["천연가스"] = S5001CommodityTarget.NaturalGas,
+ ["백금"] = S5001CommodityTarget.Platinum,
+ ["팔라듐"] = S5001CommodityTarget.Palladium,
+ ["납"] = S5001CommodityTarget.Lead,
+ ["아연"] = S5001CommodityTarget.Zinc,
+ ["주석"] = S5001CommodityTarget.Tin,
+ ["국제 금"] = S5001CommodityTarget.InternationalGold,
+ ["국제 은"] = S5001CommodityTarget.InternationalSilver,
+ ["국내 금"] = S5001CommodityTarget.DomesticGold,
+ ["국내 은"] = S5001CommodityTarget.DomesticSilver
+ };
+
+ private sealed record DomesticStockSelection(
+ LegacyDomesticEquityMarket Market,
+ string StockName);
+
+ private enum StockMasterKind
+ {
+ Kospi,
+ Kosdaq,
+ NxtKospi,
+ NxtKosdaq,
+ World
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
new file mode 100644
index 0000000..7585f81
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
@@ -0,0 +1,572 @@
+#nullable enable
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Native lookup fields corresponding to the legacy playlist's code, subject,
+/// graphic type, subtype and hidden data-code columns. They may select parameterized
+/// DB data but never carry K3D method names, object names, mutations or asset paths.
+///
+public sealed record LegacySceneSelection(
+ string GroupCode,
+ string Subject,
+ string GraphicType,
+ string Subtype,
+ string DataCode);
+
+public sealed record LegacyPlaylistEntry(
+ string EntryId,
+ string CutCode,
+ bool IsEnabled = true,
+ int? FadeDuration = null,
+ LegacySceneSelection? Selection = null);
+
+public sealed record LegacySceneCuePage(
+ PlayoutCue Cue,
+ int ItemCount,
+ ScenePageSize? PageSize = null,
+ string? BuilderKey = null,
+ IReadOnlyList? PreviewFields = null);
+
+///
+/// Native-only data boundary. Implementations query DB data and invoke a registered typed
+/// scene builder; Web input can select a catalog code but cannot supply mutations or paths.
+///
+public interface ILegacySceneCueProvider
+{
+ Task CreatePageAsync(
+ LegacyPlaylistEntry entry,
+ int pageIndexZeroBased,
+ CancellationToken cancellationToken = default);
+}
+
+public enum LegacyWorkflowNextKind
+{
+ None,
+ PageNext,
+ PlaylistNext,
+ EndOfPlaylist
+}
+
+public sealed record LegacyPlayoutWorkflowState(
+ int CurrentCueIndexZeroBased,
+ string? PreparedCutCode,
+ string? OnAirCutCode,
+ int PageIndexZeroBased,
+ int PageCount,
+ int PageSize,
+ int ItemCount,
+ bool IsLastPage,
+ LegacyWorkflowNextKind NextKind,
+ string? CurrentEntryId,
+ string? BuilderKey,
+ int CurrentPageItemCount,
+ IReadOnlyList PreviewFields);
+
+///
+/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine.
+/// It is deliberately unaware of COM and serializes DB-to-cue work with engine calls.
+///
+public sealed class LegacyPlayoutWorkflow
+{
+ private const int MaximumPlaylistItems = 1_000;
+
+ private readonly IPlayoutEngine _engine;
+ private readonly ILegacySceneCueProvider _cueProvider;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private IReadOnlyList _playlist = [];
+ private ActivePage? _prepared;
+ private ActivePage? _onAir;
+ private int _engineClearRequested;
+
+ public LegacyPlayoutWorkflow(
+ IPlayoutEngine engine,
+ ILegacySceneCueProvider cueProvider)
+ {
+ _engine = engine ?? throw new ArgumentNullException(nameof(engine));
+ _cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider));
+ State = EmptyState;
+ }
+
+ public LegacyPlayoutWorkflowState State { get; private set; }
+
+ ///
+ /// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native
+ /// playlist state. It never dispatches an engine command. If a command currently
+ /// owns the workflow gate, reconciliation is applied before that command releases it.
+ ///
+ public void ObserveEngineStatus(PlayoutStatus status)
+ {
+ ArgumentNullException.ThrowIfNull(status);
+ if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
+ !string.IsNullOrWhiteSpace(status.OnAirSceneName))
+ {
+ return;
+ }
+
+ Interlocked.Exchange(ref _engineClearRequested, 1);
+ if (!_gate.Wait(0))
+ {
+ return;
+ }
+
+ try
+ {
+ ApplyRequestedEngineClear();
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async Task PrepareAsync(
+ IReadOnlyList