Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs

459 lines
16 KiB
C#

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);
}
}