459 lines
16 KiB
C#
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);
|
|
}
|
|
}
|