feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyTradingHaltRequestStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum LegacyTradingHaltNameSort
|
||||
{
|
||||
None,
|
||||
Descending,
|
||||
Ascending
|
||||
}
|
||||
|
||||
public sealed record LegacyTradingHaltResultView(
|
||||
string SelectionId,
|
||||
string Market,
|
||||
string StockCode,
|
||||
string DisplayName,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record LegacyTradingHaltActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
bool IsAvailable);
|
||||
|
||||
public sealed record LegacyTradingHaltWorkflowSnapshot(
|
||||
long Revision,
|
||||
string Query,
|
||||
LegacyTradingHaltRequestStatus Status,
|
||||
IReadOnlyList<LegacyTradingHaltResultView> Results,
|
||||
bool IsTruncated,
|
||||
LegacyTradingHaltResultView? Selected,
|
||||
LegacyTradingHaltNameSort NameSort,
|
||||
bool Ma5,
|
||||
bool Ma20,
|
||||
IReadOnlyList<LegacyTradingHaltActionView> Actions,
|
||||
string ErrorMessage,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
public sealed record LegacyTradingHaltPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
string StockCode,
|
||||
string DisplayName,
|
||||
LegacySceneSelection Selection,
|
||||
bool Ma5,
|
||||
bool Ma20,
|
||||
int? CandlePeriodDays,
|
||||
string PageText,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string Source,
|
||||
string ActionId)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native authority for UC7's combined KOSPI/KOSDAQ trading-halt selector.
|
||||
/// The Web selects an opaque row and a closed action id; stock identity and scene fields
|
||||
/// always come from the validated Core service result.
|
||||
/// </summary>
|
||||
public sealed class LegacyTradingHaltWorkflowController
|
||||
{
|
||||
public const string CurrentActionId = "current";
|
||||
public const string CandleActionId = "candle-120d";
|
||||
public const int CandlePeriodDays = 120;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string CurrentLabel = "거래정지-현재가";
|
||||
private const string CandleLabel = "캔들그래프-거래정지 · 120일";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ITradingHaltSelectionService _service;
|
||||
private List<HaltState> _results = [];
|
||||
private HaltState? _selected;
|
||||
private string _query = string.Empty;
|
||||
private string _errorMessage = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyTradingHaltRequestStatus _status = LegacyTradingHaltRequestStatus.Idle;
|
||||
private LegacyTradingHaltNameSort _nameSort;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private bool _isTruncated;
|
||||
private bool _ma5;
|
||||
private bool _ma20;
|
||||
private int _searchGeneration;
|
||||
private long _revision;
|
||||
private long _draftSequence;
|
||||
|
||||
public LegacyTradingHaltWorkflowController(ITradingHaltSelectionService service)
|
||||
{
|
||||
_service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyTradingHaltWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
generation = ++_searchGeneration;
|
||||
_query = normalizedQuery;
|
||||
_results = [];
|
||||
_selected = null;
|
||||
_nameSort = LegacyTradingHaltNameSort.None;
|
||||
_status = LegacyTradingHaltRequestStatus.Loading;
|
||||
_isTruncated = false;
|
||||
_errorMessage = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.SearchAsync(
|
||||
normalizedQuery,
|
||||
LegacyTradingHaltSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _searchGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_results = ValidateResult(result, normalizedQuery, generation);
|
||||
_isTruncated = result.IsTruncated;
|
||||
_status = LegacyTradingHaltRequestStatus.Ready;
|
||||
_errorMessage = string.Empty;
|
||||
_statusMessage = $"KOSPI·KOSDAQ 거래정지 종목 {_results.Count}개를 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_status = LegacyTradingHaltRequestStatus.Idle;
|
||||
_errorMessage = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_results = [];
|
||||
_selected = null;
|
||||
_status = LegacyTradingHaltRequestStatus.Error;
|
||||
_errorMessage = "거래정지 종목 조회 결과를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _errorMessage;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot SelectResult(string selectionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
lock (_gate)
|
||||
{
|
||||
_selected = _results.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(selectionId),
|
||||
"The trading-halt selection id is not in the current result set.");
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot ToggleNameSort()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_nameSort = _nameSort switch
|
||||
{
|
||||
LegacyTradingHaltNameSort.Descending => LegacyTradingHaltNameSort.Ascending,
|
||||
_ => LegacyTradingHaltNameSort.Descending
|
||||
};
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_ma5 = ma5;
|
||||
_ma20 = ma20;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyTradingHaltPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
lock (_gate)
|
||||
{
|
||||
var selected = _selected ??
|
||||
throw new InvalidOperationException("Select a trading-halt stock first.");
|
||||
var isCurrent = string.Equals(actionId, CurrentActionId, StringComparison.Ordinal);
|
||||
var isCandle = string.Equals(actionId, CandleActionId, StringComparison.Ordinal);
|
||||
if (!isCurrent && !isCandle)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId));
|
||||
}
|
||||
|
||||
var item = selected.Selection;
|
||||
var market = item.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq";
|
||||
var builderKey = isCurrent ? "s5001" : "s8010";
|
||||
var cutCode = isCurrent ? "5001" : "8051";
|
||||
var graphicType = isCurrent
|
||||
? "1열판기본"
|
||||
: string.Join(
|
||||
',',
|
||||
new[]
|
||||
{
|
||||
_ma5 ? "MA5" : null,
|
||||
_ma20 ? "MA20" : null
|
||||
}.Where(static value => value is not null));
|
||||
var selection = new LegacySceneSelection(
|
||||
isCurrent
|
||||
? item.Market == TradingHaltMarket.Kospi ? "KOSPI" : "KOSDAQ"
|
||||
: item.Market == TradingHaltMarket.Kospi
|
||||
? "KOSPI_STOCK"
|
||||
: "KOSDAQ_STOCK",
|
||||
item.DisplayName,
|
||||
graphicType,
|
||||
isCurrent ? "HALTED" : "TRADING_HALT",
|
||||
item.StockCode);
|
||||
return new LegacyTradingHaltPlaylistDraft(
|
||||
$"trading-halt-draft-{++_draftSequence:D8}",
|
||||
builderKey,
|
||||
cutCode,
|
||||
Array.AsReadOnly(new[] { cutCode }),
|
||||
item.DisplayName,
|
||||
isCurrent ? CurrentLabel : CandleLabel,
|
||||
isCurrent ? "stock" : "chart",
|
||||
market,
|
||||
item.StockCode,
|
||||
item.DisplayName,
|
||||
selection,
|
||||
isCandle && _ma5,
|
||||
isCandle && _ma20,
|
||||
isCandle ? CandlePeriodDays : null,
|
||||
"1/1",
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
"legacy-trading-halt-workflow",
|
||||
actionId);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyTradingHaltWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var display = _results.ToArray();
|
||||
if (_nameSort != LegacyTradingHaltNameSort.None)
|
||||
{
|
||||
var multiplier = _nameSort == LegacyTradingHaltNameSort.Ascending ? 1 : -1;
|
||||
display = display
|
||||
.Select((item, sourceIndex) => (item, sourceIndex))
|
||||
.OrderBy(pair => pair.item.Selection.DisplayName, new DirectionComparer(multiplier))
|
||||
.ThenBy(pair => pair.item.Selection.Market)
|
||||
.ThenBy(pair => pair.item.Selection.StockCode, StringComparer.Ordinal)
|
||||
.ThenBy(pair => pair.sourceIndex)
|
||||
.Select(pair => pair.item)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var rows = display.Select(ToView).ToArray();
|
||||
var selected = _selected is null ? null : ToView(_selected);
|
||||
var canAct = _selected is not null;
|
||||
return new LegacyTradingHaltWorkflowSnapshot(
|
||||
_revision,
|
||||
_query,
|
||||
_status,
|
||||
Array.AsReadOnly(rows),
|
||||
_isTruncated,
|
||||
selected,
|
||||
_nameSort,
|
||||
_ma5,
|
||||
_ma20,
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyTradingHaltActionView(CurrentActionId, CurrentLabel, canAct),
|
||||
new LegacyTradingHaltActionView(CandleActionId, CandleLabel, canAct)
|
||||
}),
|
||||
_errorMessage,
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private LegacyTradingHaltResultView ToView(HaltState state) =>
|
||||
new(
|
||||
state.SelectionId,
|
||||
state.Selection.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq",
|
||||
state.Selection.StockCode,
|
||||
state.Selection.DisplayName,
|
||||
_selected?.Selection == state.Selection);
|
||||
|
||||
private static List<HaltState> ValidateResult(
|
||||
TradingHaltSelectionResult result,
|
||||
string expectedQuery,
|
||||
int generation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
|
||||
result.Items is null ||
|
||||
result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults ||
|
||||
result.IsTruncated &&
|
||||
result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults)
|
||||
{
|
||||
throw new TradingHaltSelectionDataException(
|
||||
"The trading-halt search response correlation is invalid.");
|
||||
}
|
||||
|
||||
var states = new List<HaltState>(result.Items.Count);
|
||||
var identities = new HashSet<(TradingHaltMarket Market, string Code)>();
|
||||
var names = new HashSet<(TradingHaltMarket Market, string Name)>();
|
||||
TradingHaltSelection? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index] ??
|
||||
throw new TradingHaltSelectionDataException("A trading-halt row is missing.");
|
||||
if (!Enum.IsDefined(item.Market) ||
|
||||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
|
||||
!IsCanonicalText(item.DisplayName, 200) ||
|
||||
!identities.Add((item.Market, item.StockCode)) ||
|
||||
!names.Add((item.Market, item.DisplayName)) ||
|
||||
previous is not null && CompareSourceOrder(previous, item) > 0)
|
||||
{
|
||||
throw new TradingHaltSelectionDataException(
|
||||
"Trading-halt identities must be canonical, unique, and KOSPI/KOSDAQ ordered.");
|
||||
}
|
||||
|
||||
states.Add(new HaltState(
|
||||
$"trading-halt-result-{generation:D4}-{index + 1:D4}",
|
||||
item));
|
||||
previous = item;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private static int CompareSourceOrder(
|
||||
TradingHaltSelection left,
|
||||
TradingHaltSelection right)
|
||||
{
|
||||
var market = left.Market.CompareTo(right.Market);
|
||||
if (market != 0)
|
||||
{
|
||||
return market;
|
||||
}
|
||||
|
||||
var name = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal);
|
||||
return name != 0
|
||||
? name
|
||||
: string.Compare(left.StockCode, right.StockCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var normalized = query.Trim();
|
||||
if (query.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
|
||||
normalized.Length > LegacyTradingHaltSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(query) || !IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A trading-halt query must contain at most 64 safe characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(static character => char.IsAsciiLetterOrDigit(character));
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record HaltState(
|
||||
string SelectionId,
|
||||
TradingHaltSelection Selection);
|
||||
|
||||
private sealed class DirectionComparer(int multiplier) : IComparer<string>
|
||||
{
|
||||
public int Compare(string? left, string? right) =>
|
||||
multiplier * string.Compare(left, right, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user