feat: complete legacy UI and playout parity migration
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the already parameterized domestic/world lookup services to the closed UC3
|
||||
/// comparison identity model. Database identities never cross the WebView boundary.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
|
||||
{
|
||||
private readonly ILegacyStockLookup _domesticLookup;
|
||||
private readonly IWorldStockSearchService _worldLookup;
|
||||
|
||||
public LegacyComparisonDataService(
|
||||
ILegacyStockLookup domesticLookup,
|
||||
IWorldStockSearchService worldLookup)
|
||||
{
|
||||
_domesticLookup = domesticLookup ?? throw new ArgumentNullException(nameof(domesticLookup));
|
||||
_worldLookup = worldLookup ?? throw new ArgumentNullException(nameof(worldLookup));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.DomesticMaximumResults);
|
||||
var result = await _domesticLookup.SearchAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var targets = new List<LegacyComparisonTarget>(
|
||||
Math.Min(result.DisplayNames.Count, maximumResults));
|
||||
var identities = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var displayName in result.DisplayNames)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var identity = result.ResolveLastByDisplayName(displayName);
|
||||
if (identity is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = ToComparisonTarget(identity);
|
||||
if (identities.Add(target.TargetId))
|
||||
{
|
||||
targets.Add(target);
|
||||
if (targets.Count == maximumResults)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets.AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults);
|
||||
var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return Array.AsReadOnly(result.Items
|
||||
.Select(item => LegacyComparisonTarget.CreateWorldStock(
|
||||
item.InputName,
|
||||
item.Symbol,
|
||||
item.NationCode))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference target,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (target.Market.Length == 0)
|
||||
{
|
||||
var world = await SearchWorldAsync(
|
||||
target.Name,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return world.FirstOrDefault(item =>
|
||||
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
|
||||
}
|
||||
|
||||
var domestic = await SearchDomesticAsync(
|
||||
RemoveNxtSuffix(target.Name),
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return domestic.FirstOrDefault(item =>
|
||||
LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target));
|
||||
}
|
||||
|
||||
private static LegacyComparisonTarget ToComparisonTarget(LegacyStockIdentity identity)
|
||||
{
|
||||
var name = RemoveNxtSuffix(identity.DisplayName);
|
||||
return identity.Market switch
|
||||
{
|
||||
LegacyDomesticStockMarket.Kospi => LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.Kosdaq => LegacyComparisonTarget.CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket.Kosdaq,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.NxtKospi => LegacyComparisonTarget.CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket.Kospi,
|
||||
name,
|
||||
identity.StockCode),
|
||||
LegacyDomesticStockMarket.NxtKosdaq => LegacyComparisonTarget.CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket.Kosdaq,
|
||||
name,
|
||||
identity.StockCode),
|
||||
_ => throw new InvalidOperationException("The domestic stock market is invalid.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string RemoveNxtSuffix(string value) =>
|
||||
value.EndsWith("(NXT)", StringComparison.Ordinal)
|
||||
? value[..^5]
|
||||
: value;
|
||||
|
||||
private static void ValidateLimit(int value, int maximum)
|
||||
{
|
||||
if (value is < 1 || value > maximum)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trusted, fixed-path CP949 store for the original 종목비교.dat shape. Reads may fall
|
||||
/// back to a read-only seed, but all writes are atomic and confined to the local path.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonPairFileStore : ILegacyComparisonPairStore
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly string? _readOnlySeedPath;
|
||||
|
||||
public LegacyComparisonPairFileStore(string path, string? readOnlySeedPath = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
_path = Path.GetFullPath(path);
|
||||
_readOnlySeedPath = string.IsNullOrWhiteSpace(readOnlySeedPath)
|
||||
? null
|
||||
: Path.GetFullPath(readOnlySeedPath);
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = File.Exists(_path)
|
||||
? _path
|
||||
: _readOnlySeedPath is not null && File.Exists(_readOnlySeedPath)
|
||||
? _readOnlySeedPath
|
||||
: null;
|
||||
return source is null
|
||||
? null
|
||||
: await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WriteCp949Async(
|
||||
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (rows.Count > MMoneyCoderSharp.Data.LegacyComparisonFileParser.MaximumRows)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(rows));
|
||||
}
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var cp949 = Encoding.GetEncoding(
|
||||
949,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
var text = string.Join(
|
||||
'\n',
|
||||
rows.Select((row, index) => SerializeRow(row, index + 1)));
|
||||
var bytes = cp949.GetBytes(text);
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
_ = MMoneyCoderSharp.Data.LegacyComparisonFileParser.Parse(bytes);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(_path) ??
|
||||
throw new InvalidOperationException("The comparison data path has no directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
var temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporaryPath, bytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
File.Move(temporaryPath, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeRow(LegacyComparisonPairWriteRow row, int expectedNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
if (row.RowNumber != expectedNumber)
|
||||
{
|
||||
throw new InvalidOperationException("Comparison row numbers must be contiguous.");
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
'^',
|
||||
row.RowNumber.ToString(CultureInfo.InvariantCulture),
|
||||
$"{row.FirstName},{row.SecondName}",
|
||||
row.FirstMarket,
|
||||
row.SecondMarket,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
internal enum LegacyComparisonActionKind
|
||||
{
|
||||
TwoColumn,
|
||||
Comparison,
|
||||
Return
|
||||
}
|
||||
|
||||
internal sealed record LegacyComparisonActionDefinition(
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
LegacyComparisonActionKind Kind,
|
||||
string? Mode = null,
|
||||
string? BuilderKey = null,
|
||||
string? CutCode = null,
|
||||
string? GraphicType = null,
|
||||
string? Period = null);
|
||||
|
||||
internal sealed record LegacyComparisonCompatibility(
|
||||
bool IsAvailable,
|
||||
string? Reason,
|
||||
string? EffectiveMode,
|
||||
string? Warning);
|
||||
|
||||
internal static class LegacyComparisonWorkflowCatalog
|
||||
{
|
||||
public const int DefaultFadeDuration = 6;
|
||||
public const int DomesticMaximumResults = 200;
|
||||
public const int WorldMaximumResults = 100;
|
||||
|
||||
private static readonly ReadOnlyCollection<LegacyComparisonTarget> FixedTargetList =
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
Market("kospi", "코스피 지수", "Kospi"),
|
||||
Market("kosdaq", "코스닥 지수", "Kosdaq"),
|
||||
Market("kospi200", "코스피200 지수", "Kospi200"),
|
||||
Market("futures", "선물 지수", "Futures"),
|
||||
Market("krx100", "KRX100지수", "Krx100"),
|
||||
Market("won-dollar", "환율-원달러", "WonDollar"),
|
||||
Market("won-yen", "환율-원엔", "WonYen"),
|
||||
Market("won-yuan", "환율-원위엔", "WonYuan"),
|
||||
Market("won-euro", "환율-원유로", "WonEuro"),
|
||||
Market("dow", "해외지수-다우", "Dow"),
|
||||
Market("nasdaq", "해외지수-나스닥", "Nasdaq"),
|
||||
Market("sp500", "해외지수-S&P", "Sp500"),
|
||||
Market("germany-dax", "해외지수-독일", "GermanyDax"),
|
||||
Market("united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"),
|
||||
Market("france-cac", "해외지수-프랑스", "FranceCac"),
|
||||
Market("nikkei", "해외지수-일본", "Nikkei"),
|
||||
Market("hang-seng", "해외지수-홍콩", "HangSeng"),
|
||||
Market("taiwan-weighted", "해외지수-대만", "TaiwanWeighted"),
|
||||
Market("singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"),
|
||||
Market("thailand-set", "해외지수-태국", "ThailandSet"),
|
||||
Market("philippines-composite", "해외지수-필리핀", "PhilippinesComposite"),
|
||||
Market("malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"),
|
||||
Market("indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"),
|
||||
Market("shanghai-composite", "해외지수-중국", "ShanghaiComposite")
|
||||
});
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedById =
|
||||
FixedTargetList.ToDictionary(static target => target.TargetId, StringComparer.Ordinal);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonTarget> FixedByDisplayName =
|
||||
FixedTargetList.ToDictionary(static target => target.DisplayName, StringComparer.Ordinal);
|
||||
|
||||
private static readonly ReadOnlyCollection<LegacyComparisonActionDefinition> ActionList =
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyComparisonActionDefinition(
|
||||
"two-column-expected", "2열판 예상체결", "2열판",
|
||||
LegacyComparisonActionKind.TwoColumn, Mode: "EXPECTED"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"two-column-current", "2열판", "2열판",
|
||||
LegacyComparisonActionKind.TwoColumn, Mode: "CURRENT"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"comparison-candle", "종목별 비교분석_캔들 그래프", "종목별 비교분석",
|
||||
LegacyComparisonActionKind.Comparison,
|
||||
BuilderKey: "s5026", CutCode: "5026",
|
||||
GraphicType: "COMPARISON_CANDLE", Period: "FiveDays"),
|
||||
new LegacyComparisonActionDefinition(
|
||||
"comparison-line", "종목별 비교분석_라인 그래프", "종목별 비교분석",
|
||||
LegacyComparisonActionKind.Comparison,
|
||||
BuilderKey: "s5087", CutCode: "5087",
|
||||
GraphicType: "COMPARISON_LINE", Period: "FiveDays"),
|
||||
Return("return-5d", "5일", "FiveDays"),
|
||||
Return("return-1m", "1개월", "OneMonth"),
|
||||
Return("return-3m", "3개월", "ThreeMonths"),
|
||||
Return("return-6m", "6개월", "SixMonths"),
|
||||
Return("return-12m", "12개월", "TwelveMonths")
|
||||
});
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyComparisonActionDefinition> ActionsById =
|
||||
ActionList.ToDictionary(static action => action.ActionId, StringComparer.Ordinal);
|
||||
|
||||
private static readonly HashSet<string> ExpectedMarketTargets =
|
||||
new(StringComparer.Ordinal) { "Kospi", "Kosdaq", "Kospi200", "Krx100" };
|
||||
|
||||
static LegacyComparisonWorkflowCatalog()
|
||||
{
|
||||
if (FixedTargetList.Count != 24 || FixedById.Count != 24 ||
|
||||
ActionList.Count != 9 || ActionsById.Count != 9)
|
||||
{
|
||||
throw new InvalidOperationException("The closed UC3 comparison catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
|
||||
|
||||
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
|
||||
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
|
||||
|
||||
public static LegacyComparisonTarget RequireCanonicalTarget(LegacyComparisonTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (target.Kind != LegacyComparisonTargetKind.MarketTarget)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
if (!FixedById.TryGetValue(target.TargetId, out var canonical) || canonical != target)
|
||||
{
|
||||
throw new InvalidOperationException("The data service returned an unknown market target.");
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
public static LegacyComparisonTargetView ToView(
|
||||
LegacyComparisonTarget target,
|
||||
string selectionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
LegacyComparisonValueGuard.RequireIdentifier(selectionId, 128, nameof(selectionId));
|
||||
var metadata = target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
|
||||
LegacyComparisonTargetKind.WorldStock => $"{target.Nation} · {target.Symbol}",
|
||||
LegacyComparisonTargetKind.KrxStock =>
|
||||
$"{MarketText(target.DomesticMarket)} KRX · {target.StockCode}",
|
||||
LegacyComparisonTargetKind.NxtStock =>
|
||||
$"{MarketText(target.DomesticMarket)} NXT · {target.StockCode}",
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
return new LegacyComparisonTargetView(
|
||||
selectionId,
|
||||
target.DisplayName,
|
||||
metadata,
|
||||
target.Kind);
|
||||
}
|
||||
|
||||
public static LegacyComparisonStoredTargetReference ToStoredReference(
|
||||
LegacyComparisonTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
return target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget =>
|
||||
new LegacyComparisonStoredTargetReference(target.DisplayName, string.Empty),
|
||||
LegacyComparisonTargetKind.KrxStock =>
|
||||
new LegacyComparisonStoredTargetReference(
|
||||
target.StockName!,
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
|
||||
? "코스피"
|
||||
: "코스닥"),
|
||||
LegacyComparisonTargetKind.NxtStock =>
|
||||
new LegacyComparisonStoredTargetReference(
|
||||
target.DisplayName,
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi
|
||||
? "코스피_NXT"
|
||||
: "코스닥_NXT"),
|
||||
LegacyComparisonTargetKind.WorldStock =>
|
||||
new LegacyComparisonStoredTargetReference(target.InputName!, string.Empty),
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
}
|
||||
|
||||
public static bool MatchesStoredReference(
|
||||
LegacyComparisonTarget target,
|
||||
LegacyComparisonStoredTargetReference stored) =>
|
||||
ToStoredReference(target) == stored;
|
||||
|
||||
public static LegacyComparisonTarget? TryResolveFixedStoredTarget(
|
||||
LegacyComparisonStoredTargetReference stored) =>
|
||||
stored.Market.Length == 0 &&
|
||||
FixedByDisplayName.TryGetValue(stored.Name, out var target)
|
||||
? target
|
||||
: null;
|
||||
|
||||
public static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second)
|
||||
{
|
||||
var views = ActionList.Select(action =>
|
||||
{
|
||||
var compatibility = GetCompatibility(action, first, second);
|
||||
return new LegacyComparisonActionView(
|
||||
action.ActionId,
|
||||
action.Label,
|
||||
action.Section,
|
||||
compatibility.IsAvailable,
|
||||
compatibility.Reason);
|
||||
}).ToArray();
|
||||
return Array.AsReadOnly(views);
|
||||
}
|
||||
|
||||
public static LegacyGenericPlaylistDraft Materialize(
|
||||
string actionId,
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second,
|
||||
string draftId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (!ActionsById.TryGetValue(actionId, out var action))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId), "The comparison action is unknown.");
|
||||
}
|
||||
|
||||
var compatibility = GetCompatibility(action, first, second);
|
||||
if (!compatibility.IsAvailable)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The selected pair cannot use this comparison action: {compatibility.Reason}.");
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireIdentifier(draftId, 128, nameof(draftId));
|
||||
var subject = $"{SubjectToken(first)},{SubjectToken(second)}";
|
||||
string builderKey;
|
||||
string cutCode;
|
||||
string graphicType;
|
||||
string subtype;
|
||||
string dataCode;
|
||||
string category;
|
||||
if (action.Kind == LegacyComparisonActionKind.TwoColumn)
|
||||
{
|
||||
var hasFutures = IsFutures(first) || IsFutures(second);
|
||||
builderKey = hasFutures ? "s5032" : "s8018";
|
||||
cutCode = hasFutures ? "5032" : "8032";
|
||||
graphicType = "2열판";
|
||||
subtype = action.Mode!;
|
||||
dataCode = string.Empty;
|
||||
category = "plate";
|
||||
}
|
||||
else
|
||||
{
|
||||
builderKey = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "s5029"
|
||||
: action.BuilderKey!;
|
||||
cutCode = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "5029"
|
||||
: action.CutCode!;
|
||||
graphicType = action.Kind == LegacyComparisonActionKind.Return
|
||||
? "RETURN_COMPARISON"
|
||||
: action.GraphicType!;
|
||||
subtype = action.Period!;
|
||||
dataCode = ExactKrxIdentityEnvelope(first, second);
|
||||
category = "chart";
|
||||
}
|
||||
|
||||
var selection = new LegacySceneSelection(
|
||||
first.Kind == LegacyComparisonTargetKind.MarketTarget ||
|
||||
second.Kind == LegacyComparisonTargetKind.MarketTarget
|
||||
? "INDEX"
|
||||
: "STOCK",
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
dataCode);
|
||||
return new LegacyGenericPlaylistDraft(
|
||||
draftId,
|
||||
builderKey,
|
||||
cutCode,
|
||||
Array.AsReadOnly(new[] { cutCode }),
|
||||
$"{first.DisplayName} ↔ {second.DisplayName}",
|
||||
action.Label,
|
||||
category,
|
||||
"compare",
|
||||
selection,
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
"legacy-comparison-workflow",
|
||||
action.ActionId,
|
||||
compatibility.EffectiveMode,
|
||||
compatibility.Warning);
|
||||
}
|
||||
|
||||
public static string PairSignature(
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second)
|
||||
{
|
||||
var firstStored = ToStoredReference(first);
|
||||
var secondStored = ToStoredReference(second);
|
||||
return string.Join(
|
||||
'\u001f',
|
||||
firstStored.Name,
|
||||
firstStored.Market,
|
||||
secondStored.Name,
|
||||
secondStored.Market);
|
||||
}
|
||||
|
||||
private static LegacyComparisonCompatibility GetCompatibility(
|
||||
LegacyComparisonActionDefinition action,
|
||||
LegacyComparisonTarget? first,
|
||||
LegacyComparisonTarget? second)
|
||||
{
|
||||
if (first is null || second is null)
|
||||
{
|
||||
return Unavailable("pair-incomplete-or-invalid");
|
||||
}
|
||||
|
||||
if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return)
|
||||
{
|
||||
return first.Kind == LegacyComparisonTargetKind.KrxStock &&
|
||||
second.Kind == LegacyComparisonTargetKind.KrxStock
|
||||
? Available("GRAPH")
|
||||
: Unavailable("comparison-graphs-require-two-krx-stocks");
|
||||
}
|
||||
|
||||
var futuresCount = (IsFutures(first) ? 1 : 0) + (IsFutures(second) ? 1 : 0);
|
||||
if (futuresCount > 1)
|
||||
{
|
||||
return Unavailable("two-futures-targets-are-unsupported");
|
||||
}
|
||||
|
||||
if (futuresCount == 1)
|
||||
{
|
||||
var counterpart = IsFutures(first) ? second : first;
|
||||
if (counterpart.Kind != LegacyComparisonTargetKind.MarketTarget)
|
||||
{
|
||||
return Unavailable("futures-requires-a-market-target-counterpart");
|
||||
}
|
||||
|
||||
return action.Mode == "EXPECTED"
|
||||
? Available(
|
||||
"CURRENT",
|
||||
"The current Core routes a futures expected action through the current s5032 branch.")
|
||||
: Available("CURRENT");
|
||||
}
|
||||
|
||||
if (action.Mode == "EXPECTED")
|
||||
{
|
||||
var supported = IsExpectedTarget(first) && IsExpectedTarget(second);
|
||||
return supported
|
||||
? Available("EXPECTED")
|
||||
: Unavailable(
|
||||
"expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
|
||||
}
|
||||
|
||||
var hasMarket = first.Kind == LegacyComparisonTargetKind.MarketTarget ||
|
||||
second.Kind == LegacyComparisonTargetKind.MarketTarget;
|
||||
var hasWorld = first.Kind == LegacyComparisonTargetKind.WorldStock ||
|
||||
second.Kind == LegacyComparisonTargetKind.WorldStock;
|
||||
return hasMarket && hasWorld
|
||||
? Unavailable("mixed-market-and-world-is-unsupported")
|
||||
: Available("CURRENT");
|
||||
}
|
||||
|
||||
private static bool IsExpectedTarget(LegacyComparisonTarget target) =>
|
||||
target.Kind == LegacyComparisonTargetKind.KrxStock ||
|
||||
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
|
||||
ExpectedMarketTargets.Contains(target.MarketTarget!);
|
||||
|
||||
private static bool IsFutures(LegacyComparisonTarget target) =>
|
||||
target.Kind == LegacyComparisonTargetKind.MarketTarget &&
|
||||
string.Equals(target.MarketTarget, "Futures", StringComparison.Ordinal);
|
||||
|
||||
private static string SubjectToken(LegacyComparisonTarget target) => target.Kind switch
|
||||
{
|
||||
LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!,
|
||||
LegacyComparisonTargetKind.KrxStock => target.StockName!,
|
||||
LegacyComparisonTargetKind.NxtStock => target.DisplayName,
|
||||
LegacyComparisonTargetKind.WorldStock => target.InputName!,
|
||||
_ => throw new InvalidOperationException("The comparison target kind is invalid.")
|
||||
};
|
||||
|
||||
private static string ExactKrxIdentityEnvelope(
|
||||
LegacyComparisonTarget first,
|
||||
LegacyComparisonTarget second)
|
||||
{
|
||||
if (first.Kind != LegacyComparisonTargetKind.KrxStock ||
|
||||
second.Kind != LegacyComparisonTargetKind.KrxStock)
|
||||
{
|
||||
throw new InvalidOperationException("Comparison graphs require two KRX stocks.");
|
||||
}
|
||||
|
||||
return $"{MarketIdentity(first)}:{first.StockCode}|{MarketIdentity(second)}:{second.StockCode}";
|
||||
}
|
||||
|
||||
private static string MarketIdentity(LegacyComparisonTarget target) =>
|
||||
target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi ? "KOSPI" : "KOSDAQ";
|
||||
|
||||
private static string MarketText(LegacyComparisonDomesticMarket? market) => market switch
|
||||
{
|
||||
LegacyComparisonDomesticMarket.Kospi => "KOSPI",
|
||||
LegacyComparisonDomesticMarket.Kosdaq => "KOSDAQ",
|
||||
_ => throw new InvalidOperationException("The domestic comparison market is missing.")
|
||||
};
|
||||
|
||||
private static LegacyComparisonCompatibility Available(
|
||||
string effectiveMode,
|
||||
string? warning = null) =>
|
||||
new(true, null, effectiveMode, warning);
|
||||
|
||||
private static LegacyComparisonCompatibility Unavailable(string reason) =>
|
||||
new(false, reason, null, null);
|
||||
|
||||
private static LegacyComparisonTarget Market(
|
||||
string id,
|
||||
string displayName,
|
||||
string target) =>
|
||||
LegacyComparisonTarget.CreateMarketTarget(id, displayName, target);
|
||||
|
||||
private static LegacyComparisonActionDefinition Return(
|
||||
string id,
|
||||
string label,
|
||||
string period) =>
|
||||
new(
|
||||
id,
|
||||
label,
|
||||
"종목별 수익률 비교",
|
||||
LegacyComparisonActionKind.Return,
|
||||
Period: period);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the original UC3 comparison workflow. Search results and fixed targets
|
||||
/// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and
|
||||
/// an action id is the only caller-supplied value used to materialize a playlist draft.
|
||||
/// </summary>
|
||||
public sealed class LegacyComparisonWorkflowController
|
||||
{
|
||||
private readonly ILegacyComparisonDataService _dataService;
|
||||
private readonly ILegacyComparisonPairStore _pairStore;
|
||||
private List<SearchResultState> _domesticResults = [];
|
||||
private List<SearchResultState> _worldResults = [];
|
||||
private List<SavedPairState> _savedPairs = [];
|
||||
private LegacyComparisonTarget? _firstTarget;
|
||||
private LegacyComparisonTarget? _secondTarget;
|
||||
private string? _selectedDomesticResultId;
|
||||
private string? _selectedWorldResultId;
|
||||
private string? _selectedFixedTargetId;
|
||||
private string? _selectedPairId;
|
||||
private string _domesticQuery = string.Empty;
|
||||
private string _worldQuery = string.Empty;
|
||||
private string _domesticError = string.Empty;
|
||||
private string _worldError = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle;
|
||||
private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private long _revision;
|
||||
private long _pairSequence;
|
||||
private long _draftSequence;
|
||||
private int _domesticSearchVersion;
|
||||
private int _worldSearchVersion;
|
||||
private bool _isBusy;
|
||||
|
||||
public LegacyComparisonWorkflowController(
|
||||
ILegacyComparisonDataService dataService,
|
||||
ILegacyComparisonPairStore pairStore)
|
||||
{
|
||||
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
|
||||
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> SearchDomesticAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeSearchQuery(query);
|
||||
var version = ++_domesticSearchVersion;
|
||||
_domesticQuery = normalizedQuery;
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Loading;
|
||||
_domesticError = string.Empty;
|
||||
_domesticResults = [];
|
||||
_selectedDomesticResultId = null;
|
||||
Touch();
|
||||
|
||||
try
|
||||
{
|
||||
var results = await _dataService.SearchDomesticAsync(
|
||||
normalizedQuery,
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version != _domesticSearchVersion)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_domesticResults = ValidateSearchResults(
|
||||
results,
|
||||
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
|
||||
domestic: true,
|
||||
version);
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Ready;
|
||||
_domesticError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (version == _domesticSearchVersion)
|
||||
{
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Idle;
|
||||
_domesticError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (version == _domesticSearchVersion)
|
||||
{
|
||||
_domesticResults = [];
|
||||
_domesticStatus = LegacyComparisonSearchStatus.Error;
|
||||
_domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> SearchWorldAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
|
||||
var normalizedQuery = NormalizeSearchQuery(query);
|
||||
var version = ++_worldSearchVersion;
|
||||
_worldQuery = normalizedQuery;
|
||||
_worldStatus = LegacyComparisonSearchStatus.Loading;
|
||||
_worldError = string.Empty;
|
||||
_worldResults = [];
|
||||
_selectedWorldResultId = null;
|
||||
Touch();
|
||||
|
||||
try
|
||||
{
|
||||
var results = await _dataService.SearchWorldAsync(
|
||||
normalizedQuery,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version != _worldSearchVersion)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_worldResults = ValidateSearchResults(
|
||||
results,
|
||||
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
|
||||
domestic: false,
|
||||
version);
|
||||
_worldStatus = LegacyComparisonSearchStatus.Ready;
|
||||
_worldError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (version == _worldSearchVersion)
|
||||
{
|
||||
_worldStatus = LegacyComparisonSearchStatus.Idle;
|
||||
_worldError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (version == _worldSearchVersion)
|
||||
{
|
||||
_worldResults = [];
|
||||
_worldStatus = LegacyComparisonSearchStatus.Error;
|
||||
_worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) =>
|
||||
SelectSearchResult(_domesticResults, selectionId, domestic: true);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) =>
|
||||
SelectSearchResult(_worldResults, selectionId, domestic: false);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetId);
|
||||
if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null)
|
||||
{
|
||||
_selectedFixedTargetId = targetId;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) =>
|
||||
ChooseSearchResult(_domesticResults, selectionId, domestic: true);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) =>
|
||||
ChooseSearchResult(_worldResults, selectionId, domestic: false);
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetId);
|
||||
var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId);
|
||||
if (target is not null)
|
||||
{
|
||||
_selectedFixedTargetId = targetId;
|
||||
RotateDraft(target);
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SwapDraftTargets()
|
||||
{
|
||||
(_firstTarget, _secondTarget) = (_secondTarget, _firstTarget);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot ClearDraftTargets()
|
||||
{
|
||||
_firstTarget = null;
|
||||
_secondTarget = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> LoadSavedPairsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BeginBusy();
|
||||
try
|
||||
{
|
||||
var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false);
|
||||
var candidate = new List<SavedPairState>();
|
||||
if (source is { Length: > 0 })
|
||||
{
|
||||
var bytes = source.ToArray();
|
||||
var rows = LegacyComparisonFileParser.Parse(bytes);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var firstReference = new LegacyComparisonStoredTargetReference(
|
||||
row.First.Name,
|
||||
row.First.Market);
|
||||
var secondReference = new LegacyComparisonStoredTargetReference(
|
||||
row.Second.Name,
|
||||
row.Second.Market);
|
||||
var first = await ResolveStoredTargetAsync(
|
||||
firstReference,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var second = await ResolveStoredTargetAsync(
|
||||
secondReference,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
candidate.Add(new SavedPairState(
|
||||
$"comparison-pair-{row.RowNumber:D8}",
|
||||
first,
|
||||
second));
|
||||
}
|
||||
}
|
||||
|
||||
_savedPairs = candidate;
|
||||
_pairSequence = Math.Max(_pairSequence, candidate.Count);
|
||||
_selectedPairId = candidate.FirstOrDefault()?.RowId;
|
||||
_statusMessage = candidate.Count == 0
|
||||
? "저장된 종목 비교쌍이 없습니다."
|
||||
: $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndBusy();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> AddDraftPairAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_firstTarget is null || _secondTarget is null)
|
||||
{
|
||||
throw new InvalidOperationException("Select two comparison targets first.");
|
||||
}
|
||||
|
||||
if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows)
|
||||
{
|
||||
throw new InvalidOperationException("The saved comparison pair limit was reached.");
|
||||
}
|
||||
|
||||
var signature = LegacyComparisonWorkflowCatalog.PairSignature(
|
||||
_firstTarget,
|
||||
_secondTarget);
|
||||
if (_savedPairs.Any(pair => string.Equals(
|
||||
LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second),
|
||||
signature,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("The exact comparison pair is already saved.");
|
||||
}
|
||||
|
||||
var rowId = $"comparison-pair-{++_pairSequence:D8}";
|
||||
var candidate = new List<SavedPairState>(_savedPairs)
|
||||
{
|
||||
new(rowId, _firstTarget, _secondTarget)
|
||||
};
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
rowId,
|
||||
"종목 비교쌍을 저장했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rowId);
|
||||
if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal)))
|
||||
{
|
||||
_selectedPairId = rowId;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> MoveSelectedPairAsync(
|
||||
LegacyComparisonPairMoveDirection direction,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enum.IsDefined(direction))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(direction));
|
||||
}
|
||||
|
||||
var index = SelectedPairIndex();
|
||||
var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1);
|
||||
if (index < 0 || destination < 0 || destination >= _savedPairs.Count)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var candidate = new List<SavedPairState>(_savedPairs);
|
||||
(candidate[index], candidate[destination]) = (candidate[destination], candidate[index]);
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
_selectedPairId,
|
||||
"종목 비교쌍 순서를 변경했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyComparisonWorkflowSnapshot> DeleteSelectedPairAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var index = SelectedPairIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var candidate = new List<SavedPairState>(_savedPairs);
|
||||
candidate.RemoveAt(index);
|
||||
var nextSelection = candidate.Count == 0
|
||||
? null
|
||||
: candidate[Math.Min(index, candidate.Count - 1)].RowId;
|
||||
return await CommitPairsAsync(
|
||||
candidate,
|
||||
nextSelection,
|
||||
"선택한 종목 비교쌍을 삭제했습니다.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<LegacyComparisonWorkflowSnapshot> DeleteAllPairsAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_savedPairs.Count == 0
|
||||
? Task.FromResult(CreateSnapshot())
|
||||
: CommitPairsAsync(
|
||||
[],
|
||||
null,
|
||||
"종목 비교쌍을 모두 삭제했습니다.",
|
||||
cancellationToken);
|
||||
|
||||
public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (_isBusy)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison workflow is busy.");
|
||||
}
|
||||
|
||||
var index = SelectedPairIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Select a saved comparison pair first.");
|
||||
}
|
||||
|
||||
var pair = _savedPairs[index];
|
||||
var draftId = $"comparison-draft-{++_draftSequence:D8}";
|
||||
return LegacyComparisonWorkflowCatalog.Materialize(
|
||||
actionId,
|
||||
pair.First,
|
||||
pair.Second,
|
||||
draftId);
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot ChooseSearchResult(
|
||||
IReadOnlyList<SearchResultState> results,
|
||||
string selectionId,
|
||||
bool domestic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
var result = results.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal));
|
||||
if (result is not null)
|
||||
{
|
||||
if (domestic)
|
||||
{
|
||||
_selectedDomesticResultId = selectionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedWorldResultId = selectionId;
|
||||
}
|
||||
|
||||
RotateDraft(result.Target);
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot SelectSearchResult(
|
||||
IReadOnlyList<SearchResultState> results,
|
||||
string selectionId,
|
||||
bool domestic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
if (results.Any(item => string.Equals(
|
||||
item.SelectionId,
|
||||
selectionId,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
if (domestic)
|
||||
{
|
||||
_selectedDomesticResultId = selectionId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedWorldResultId = selectionId;
|
||||
}
|
||||
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private void RotateDraft(LegacyComparisonTarget selected)
|
||||
{
|
||||
_secondTarget = _firstTarget;
|
||||
_firstTarget = selected;
|
||||
}
|
||||
|
||||
private async Task<LegacyComparisonTarget> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference stored,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored);
|
||||
var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync(
|
||||
stored,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (resolved is null)
|
||||
{
|
||||
throw new InvalidOperationException("A stored comparison target no longer resolves.");
|
||||
}
|
||||
|
||||
resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved);
|
||||
if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored))
|
||||
{
|
||||
throw new InvalidOperationException("A stored comparison target resolved to a different identity.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task<LegacyComparisonWorkflowSnapshot> CommitPairsAsync(
|
||||
IReadOnlyList<SavedPairState> candidate,
|
||||
string? nextSelection,
|
||||
string successMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
BeginBusy();
|
||||
try
|
||||
{
|
||||
var writeRows = candidate.Select((pair, index) =>
|
||||
{
|
||||
var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
|
||||
var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
|
||||
return new LegacyComparisonPairWriteRow(
|
||||
index + 1,
|
||||
first.Name,
|
||||
first.Market,
|
||||
second.Name,
|
||||
second.Market);
|
||||
}).ToArray();
|
||||
await _pairStore.WriteCp949Async(
|
||||
Array.AsReadOnly(writeRows),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_savedPairs = candidate.ToList();
|
||||
_selectedPairId = nextSelection;
|
||||
_statusMessage = successMessage;
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndBusy();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
private static List<SearchResultState> ValidateSearchResults(
|
||||
IReadOnlyList<LegacyComparisonTarget>? results,
|
||||
int maximumResults,
|
||||
bool domestic,
|
||||
int version)
|
||||
{
|
||||
if (results is null || results.Count > maximumResults)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison search result count is invalid.");
|
||||
}
|
||||
|
||||
var validated = new List<SearchResultState>(results.Count);
|
||||
var targetIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < results.Count; index++)
|
||||
{
|
||||
var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]);
|
||||
var allowedKind = domestic
|
||||
? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock
|
||||
: target.Kind == LegacyComparisonTargetKind.WorldStock;
|
||||
if (!allowedKind || !targetIds.Add(target.TargetId))
|
||||
{
|
||||
throw new InvalidOperationException("The comparison search result identity is invalid.");
|
||||
}
|
||||
|
||||
validated.Add(new SearchResultState(
|
||||
$"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}",
|
||||
target));
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private LegacyComparisonWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0
|
||||
? _savedPairs[selectedIndex]
|
||||
: null;
|
||||
var domesticResults = _domesticResults
|
||||
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
|
||||
result.Target,
|
||||
result.SelectionId))
|
||||
.ToArray();
|
||||
var worldResults = _worldResults
|
||||
.Select(result => LegacyComparisonWorkflowCatalog.ToView(
|
||||
result.Target,
|
||||
result.SelectionId))
|
||||
.ToArray();
|
||||
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
|
||||
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
|
||||
.ToArray();
|
||||
var savedPairs = _savedPairs.Select((pair, index) =>
|
||||
new LegacyComparisonSavedPairView(
|
||||
pair.RowId,
|
||||
index + 1,
|
||||
$"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}",
|
||||
LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"),
|
||||
LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"),
|
||||
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
return new LegacyComparisonWorkflowSnapshot(
|
||||
_revision,
|
||||
_isBusy,
|
||||
new LegacyComparisonSearchSnapshot(
|
||||
_domesticQuery,
|
||||
_domesticStatus,
|
||||
Array.AsReadOnly(domesticResults),
|
||||
_selectedDomesticResultId,
|
||||
domesticResults.Length,
|
||||
_domesticError),
|
||||
new LegacyComparisonSearchSnapshot(
|
||||
_worldQuery,
|
||||
_worldStatus,
|
||||
Array.AsReadOnly(worldResults),
|
||||
_selectedWorldResultId,
|
||||
worldResults.Length,
|
||||
_worldError),
|
||||
Array.AsReadOnly(fixedTargets),
|
||||
_selectedFixedTargetId,
|
||||
_firstTarget is null
|
||||
? null
|
||||
: LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"),
|
||||
_secondTarget is null
|
||||
? null
|
||||
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
|
||||
Array.AsReadOnly(savedPairs),
|
||||
_selectedPairId,
|
||||
LegacyComparisonWorkflowCatalog.CreateActionViews(
|
||||
selectedPair?.First,
|
||||
selectedPair?.Second),
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private int SelectedPairIndex() => _selectedPairId is null
|
||||
? -1
|
||||
: _savedPairs.FindIndex(pair =>
|
||||
string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal));
|
||||
|
||||
private static string NormalizeSearchQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > 64 ||
|
||||
normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character)))
|
||||
{
|
||||
throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void BeginBusy()
|
||||
{
|
||||
if (_isBusy)
|
||||
{
|
||||
throw new InvalidOperationException("The comparison workflow is already busy.");
|
||||
}
|
||||
|
||||
_isBusy = true;
|
||||
Touch();
|
||||
}
|
||||
|
||||
private void EndBusy()
|
||||
{
|
||||
_isBusy = false;
|
||||
Touch();
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record SearchResultState(
|
||||
string SelectionId,
|
||||
LegacyComparisonTarget Target);
|
||||
|
||||
private sealed record SavedPairState(
|
||||
string RowId,
|
||||
LegacyComparisonTarget First,
|
||||
LegacyComparisonTarget Second);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyComparisonTargetKind
|
||||
{
|
||||
MarketTarget,
|
||||
KrxStock,
|
||||
NxtStock,
|
||||
WorldStock
|
||||
}
|
||||
|
||||
public enum LegacyComparisonDomesticMarket
|
||||
{
|
||||
Kospi,
|
||||
Kosdaq
|
||||
}
|
||||
|
||||
public enum LegacyComparisonSearchStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum LegacyComparisonPairMoveDirection
|
||||
{
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validated native comparison identity. Web code never constructs this type; adapters
|
||||
/// create it from trusted DB results and the controller only exposes a display projection.
|
||||
/// </summary>
|
||||
public sealed record LegacyComparisonTarget
|
||||
{
|
||||
private LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind kind,
|
||||
string targetId,
|
||||
string displayName,
|
||||
string? marketTarget = null,
|
||||
LegacyComparisonDomesticMarket? domesticMarket = null,
|
||||
string? stockName = null,
|
||||
string? stockCode = null,
|
||||
string? inputName = null,
|
||||
string? symbol = null,
|
||||
string? nation = null)
|
||||
{
|
||||
Kind = kind;
|
||||
TargetId = targetId;
|
||||
DisplayName = displayName;
|
||||
MarketTarget = marketTarget;
|
||||
DomesticMarket = domesticMarket;
|
||||
StockName = stockName;
|
||||
StockCode = stockCode;
|
||||
InputName = inputName;
|
||||
Symbol = symbol;
|
||||
Nation = nation;
|
||||
}
|
||||
|
||||
public LegacyComparisonTargetKind Kind { get; }
|
||||
|
||||
public string TargetId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? MarketTarget { get; }
|
||||
|
||||
public LegacyComparisonDomesticMarket? DomesticMarket { get; }
|
||||
|
||||
public string? StockName { get; }
|
||||
|
||||
public string? StockCode { get; }
|
||||
|
||||
public string? InputName { get; }
|
||||
|
||||
public string? Symbol { get; }
|
||||
|
||||
public string? Nation { get; }
|
||||
|
||||
public static LegacyComparisonTarget CreateMarketTarget(
|
||||
string targetId,
|
||||
string displayName,
|
||||
string marketTarget)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireIdentifier(targetId, 64, nameof(targetId));
|
||||
LegacyComparisonValueGuard.RequireText(displayName, 200, nameof(displayName));
|
||||
LegacyComparisonValueGuard.RequireIdentifier(marketTarget, 64, nameof(marketTarget));
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.MarketTarget,
|
||||
targetId,
|
||||
displayName,
|
||||
marketTarget: marketTarget);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateKrxStock(
|
||||
LegacyComparisonDomesticMarket market,
|
||||
string stockName,
|
||||
string stockCode)
|
||||
{
|
||||
ValidateDomesticStock(stockName, stockCode);
|
||||
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.KrxStock,
|
||||
$"krx:{marketToken}:{stockCode}",
|
||||
stockName,
|
||||
domesticMarket: market,
|
||||
stockName: stockName,
|
||||
stockCode: stockCode);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateNxtStock(
|
||||
LegacyComparisonDomesticMarket market,
|
||||
string stockName,
|
||||
string stockCode)
|
||||
{
|
||||
ValidateDomesticStock(stockName, stockCode);
|
||||
var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq";
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.NxtStock,
|
||||
$"nxt:{marketToken}:{stockCode}",
|
||||
$"{stockName}(NXT)",
|
||||
domesticMarket: market,
|
||||
stockName: stockName,
|
||||
stockCode: stockCode);
|
||||
}
|
||||
|
||||
public static LegacyComparisonTarget CreateWorldStock(
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nation)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName));
|
||||
if (inputName.Contains(',', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName));
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol));
|
||||
var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation));
|
||||
return new LegacyComparisonTarget(
|
||||
LegacyComparisonTargetKind.WorldStock,
|
||||
$"world:{normalizedNation}:{symbol}",
|
||||
inputName,
|
||||
inputName: inputName,
|
||||
symbol: symbol,
|
||||
nation: normalizedNation);
|
||||
}
|
||||
|
||||
private static void ValidateDomesticStock(string stockName, string stockCode)
|
||||
{
|
||||
LegacyComparisonValueGuard.RequireText(stockName, 200, nameof(stockName));
|
||||
if (stockName.Contains(',', StringComparison.Ordinal) ||
|
||||
stockName.Contains("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A domestic stock name cannot contain a comma or the NXT display suffix.",
|
||||
nameof(stockName));
|
||||
}
|
||||
|
||||
LegacyComparisonValueGuard.RequireStockCode(stockCode, nameof(stockCode));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LegacyComparisonStoredTargetReference(
|
||||
string Name,
|
||||
string Market);
|
||||
|
||||
public interface ILegacyComparisonDataService
|
||||
{
|
||||
Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
|
||||
string query,
|
||||
int maximumResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
|
||||
LegacyComparisonStoredTargetReference target,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949
|
||||
/// encoding; no UI request can provide a path or raw serialized row.
|
||||
/// </summary>
|
||||
public interface ILegacyComparisonPairStore
|
||||
{
|
||||
Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default);
|
||||
|
||||
Task WriteCp949Async(
|
||||
IReadOnlyList<LegacyComparisonPairWriteRow> rows,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LegacyComparisonPairWriteRow(
|
||||
int RowNumber,
|
||||
string FirstName,
|
||||
string FirstMarket,
|
||||
string SecondName,
|
||||
string SecondMarket);
|
||||
|
||||
public sealed record LegacyComparisonTargetView(
|
||||
string SelectionId,
|
||||
string DisplayName,
|
||||
string Metadata,
|
||||
LegacyComparisonTargetKind Kind);
|
||||
|
||||
public sealed record LegacyComparisonSearchSnapshot(
|
||||
string Query,
|
||||
LegacyComparisonSearchStatus Status,
|
||||
IReadOnlyList<LegacyComparisonTargetView> Results,
|
||||
string? SelectedResultId,
|
||||
int TotalRowCount,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyComparisonSavedPairView(
|
||||
string RowId,
|
||||
int RowNumber,
|
||||
string DisplayLabel,
|
||||
LegacyComparisonTargetView First,
|
||||
LegacyComparisonTargetView Second,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record LegacyComparisonActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
bool IsAvailable,
|
||||
string? UnavailableReason);
|
||||
|
||||
public sealed record LegacyComparisonWorkflowSnapshot(
|
||||
long Revision,
|
||||
bool IsBusy,
|
||||
LegacyComparisonSearchSnapshot DomesticSearch,
|
||||
LegacyComparisonSearchSnapshot WorldSearch,
|
||||
IReadOnlyList<LegacyComparisonTargetView> FixedTargets,
|
||||
string? SelectedFixedTargetId,
|
||||
LegacyComparisonTargetView? FirstTarget,
|
||||
LegacyComparisonTargetView? SecondTarget,
|
||||
IReadOnlyList<LegacyComparisonSavedPairView> SavedPairs,
|
||||
string? SelectedPairId,
|
||||
IReadOnlyList<LegacyComparisonActionView> Actions,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
/// <summary>
|
||||
/// Generic, already validated playlist materialization. Only an action id crosses the UI
|
||||
/// command boundary; builder, alias, scene selection and hidden identities are produced here.
|
||||
/// </summary>
|
||||
public sealed record LegacyGenericPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacySceneSelection Selection,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string Source,
|
||||
string ActionId,
|
||||
string? EffectiveMode,
|
||||
string? Warning)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
internal static class LegacyComparisonValueGuard
|
||||
{
|
||||
public static void RequireText(string value, int maximumLength, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 || value.Length > maximumLength ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||
value.Contains('^') ||
|
||||
value.Any(static character =>
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
return char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator;
|
||||
}))
|
||||
{
|
||||
throw new ArgumentException("A canonical comparison text value is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireIdentifier(string value, int maximumLength, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 || value.Length > maximumLength ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
|
||||
{
|
||||
throw new ArgumentException("A safe comparison identifier is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireStockCode(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 or > 32 ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-' and not '_'))
|
||||
{
|
||||
throw new ArgumentException("A safe stock code is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireSymbol(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
if (value.Length is <= 0 or > 64 ||
|
||||
value.Any(static character =>
|
||||
!char.IsAsciiLetterOrDigit(character) &&
|
||||
character is not '@' and not '.' and not '_' and not ':' and not '-'))
|
||||
{
|
||||
throw new ArgumentException("A safe world symbol is required.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
public static string RequireNation(string value, string parameterName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, parameterName);
|
||||
var normalized = value.ToUpperInvariant();
|
||||
if (normalized is not ("US" or "TW"))
|
||||
{
|
||||
throw new ArgumentException("Only the original US and TW universe is supported.", parameterName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyExpertRequestStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record LegacyExpertResultView(
|
||||
string SelectionId,
|
||||
string ExpertCode,
|
||||
string ExpertName);
|
||||
|
||||
public sealed record LegacyExpertRecommendationView(
|
||||
int PlayIndex,
|
||||
string StockCode,
|
||||
string StockName,
|
||||
decimal BuyAmount);
|
||||
|
||||
public sealed record LegacyExpertSearchSnapshot(
|
||||
string Query,
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertResultView> Results,
|
||||
bool IsTruncated,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertPreviewSnapshot(
|
||||
LegacyExpertRequestStatus Status,
|
||||
IReadOnlyList<LegacyExpertRecommendationView> Items,
|
||||
bool IsTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed record LegacyExpertActionView(
|
||||
string ActionId,
|
||||
string Label,
|
||||
bool IsAvailable);
|
||||
|
||||
public sealed record LegacyExpertWorkflowSnapshot(
|
||||
long Revision,
|
||||
LegacyExpertSearchSnapshot Search,
|
||||
LegacyExpertResultView? SelectedExpert,
|
||||
LegacyExpertPreviewSnapshot Preview,
|
||||
IReadOnlyList<LegacyExpertActionView> Actions,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind);
|
||||
|
||||
public sealed record LegacyExpertPlaylistDraft(
|
||||
string DraftId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Source,
|
||||
string ExpertCode,
|
||||
string ExpertName,
|
||||
int ItemCount,
|
||||
bool PreviewTruncated,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
string PageText,
|
||||
LegacySceneSelection Selection,
|
||||
bool IsEnabled,
|
||||
int FadeDuration,
|
||||
string ActionId)
|
||||
{
|
||||
public LegacyPlaylistEntry ToLegacyPlaylistEntry() =>
|
||||
new(DraftId, CutCode, IsEnabled, FadeDuration, Selection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native authority for UC6's read-only expert selector and recommendation preview.
|
||||
/// EList CRUD is intentionally outside this workflow.
|
||||
/// </summary>
|
||||
public sealed class LegacyExpertWorkflowController
|
||||
{
|
||||
public const string ActionId = "five-row-current";
|
||||
public const int PageSize = 5;
|
||||
public const int MaximumPageCount = 20;
|
||||
public const int MaximumAccessibleItems = PageSize * MaximumPageCount;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가";
|
||||
|
||||
private static readonly ReadOnlyCollection<string> Aliases =
|
||||
Array.AsReadOnly(new[] { "5074" });
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IExpertSelectionService _service;
|
||||
private List<ExpertState> _searchResults = [];
|
||||
private ExpertState? _selected;
|
||||
private List<ExpertRecommendationPreview> _previewItems = [];
|
||||
private string _query = string.Empty;
|
||||
private string _searchError = string.Empty;
|
||||
private string _previewError = string.Empty;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
private bool _searchTruncated;
|
||||
private bool _previewTruncated;
|
||||
private int _searchGeneration;
|
||||
private int _previewGeneration;
|
||||
private long _revision;
|
||||
private long _draftSequence;
|
||||
|
||||
public LegacyExpertWorkflowController(IExpertSelectionService service)
|
||||
{
|
||||
_service = service ?? throw new ArgumentNullException(nameof(service));
|
||||
}
|
||||
|
||||
public LegacyExpertWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
generation = ++_searchGeneration;
|
||||
_previewGeneration++;
|
||||
_query = normalizedQuery;
|
||||
_searchResults = [];
|
||||
_selected = null;
|
||||
ResetPreview();
|
||||
_searchStatus = LegacyExpertRequestStatus.Loading;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.SearchAsync(
|
||||
normalizedQuery,
|
||||
LegacyExpertSelectionService.MaximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _searchGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_searchResults = ValidateSearchResult(result, normalizedQuery, generation);
|
||||
_searchTruncated = result.IsTruncated;
|
||||
_searchStatus = LegacyExpertRequestStatus.Ready;
|
||||
_searchError = string.Empty;
|
||||
_statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchStatus = LegacyExpertRequestStatus.Idle;
|
||||
_searchError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _searchGeneration)
|
||||
{
|
||||
_searchResults = [];
|
||||
_searchStatus = LegacyExpertRequestStatus.Error;
|
||||
_searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _searchError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SelectExpertAsync(
|
||||
string selectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selectionId);
|
||||
ExpertState selected;
|
||||
int generation;
|
||||
lock (_gate)
|
||||
{
|
||||
selected = _searchResults.FirstOrDefault(item =>
|
||||
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ??
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(selectionId),
|
||||
"The expert selection id is not in the current result set.");
|
||||
_selected = selected;
|
||||
generation = ++_previewGeneration;
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Loading;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.PreviewAsync(
|
||||
selected.Identity,
|
||||
LegacyExpertSelectionService.MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation != _previewGeneration ||
|
||||
_selected?.Identity != selected.Identity)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_previewItems = ValidatePreviewResult(result, selected.Identity);
|
||||
_previewTruncated = result.IsTruncated;
|
||||
_previewStatus = LegacyExpertRequestStatus.Ready;
|
||||
_previewError = string.Empty;
|
||||
_statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Error;
|
||||
_previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다.";
|
||||
_statusMessage = _previewError;
|
||||
_statusKind = LegacyOperatorStatusKind.Error;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!string.Equals(actionId, ActionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(actionId));
|
||||
}
|
||||
|
||||
if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready)
|
||||
{
|
||||
throw new InvalidOperationException("Select and preview an expert first.");
|
||||
}
|
||||
|
||||
var itemCount = _previewItems.Count;
|
||||
if (itemCount is <= 0 or > MaximumAccessibleItems)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An expert cut requires one through 100 accessible recommendation rows.");
|
||||
}
|
||||
|
||||
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
|
||||
if (pageCount is <= 0 or > MaximumPageCount)
|
||||
{
|
||||
throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
|
||||
}
|
||||
|
||||
var identity = _selected.Identity;
|
||||
var selection = new LegacySceneSelection(
|
||||
"PAGED_EXPERT",
|
||||
identity.ExpertName,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
identity.ExpertCode);
|
||||
return new LegacyExpertPlaylistDraft(
|
||||
$"expert-draft-{++_draftSequence:D8}",
|
||||
"s5074",
|
||||
"5074",
|
||||
Aliases,
|
||||
identity.ExpertName,
|
||||
ActionLabel,
|
||||
"expert",
|
||||
"legacy-expert-workflow",
|
||||
identity.ExpertCode,
|
||||
identity.ExpertName,
|
||||
itemCount,
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
$"1/{pageCount}",
|
||||
selection,
|
||||
true,
|
||||
DefaultFadeDuration,
|
||||
ActionId);
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyExpertWorkflowSnapshot CreateSnapshot()
|
||||
{
|
||||
var searchRows = _searchResults.Select(ToView).ToArray();
|
||||
var previewRows = _previewItems.Select(item =>
|
||||
new LegacyExpertRecommendationView(
|
||||
item.PlayIndex,
|
||||
item.StockCode,
|
||||
item.StockName,
|
||||
item.BuyAmount)).ToArray();
|
||||
var pageCount = previewRows.Length == 0
|
||||
? 0
|
||||
: (previewRows.Length + PageSize - 1) / PageSize;
|
||||
var actionAvailable = _selected is not null &&
|
||||
_previewStatus == LegacyExpertRequestStatus.Ready &&
|
||||
previewRows.Length is > 0 and <= MaximumAccessibleItems &&
|
||||
pageCount <= MaximumPageCount;
|
||||
return new LegacyExpertWorkflowSnapshot(
|
||||
_revision,
|
||||
new LegacyExpertSearchSnapshot(
|
||||
_query,
|
||||
_searchStatus,
|
||||
Array.AsReadOnly(searchRows),
|
||||
_searchTruncated,
|
||||
_searchError),
|
||||
_selected is null ? null : ToView(_selected),
|
||||
new LegacyExpertPreviewSnapshot(
|
||||
_previewStatus,
|
||||
Array.AsReadOnly(previewRows),
|
||||
_previewTruncated,
|
||||
PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
_previewError),
|
||||
Array.AsReadOnly(new[]
|
||||
{
|
||||
new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable)
|
||||
}),
|
||||
_statusMessage,
|
||||
_statusKind);
|
||||
}
|
||||
|
||||
private static List<ExpertState> ValidateSearchResult(
|
||||
ExpertSearchResult result,
|
||||
string expectedQuery,
|
||||
int generation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
|
||||
result.Items is null ||
|
||||
result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
|
||||
result.IsTruncated &&
|
||||
result.Items.Count != LegacyExpertSelectionService.MaximumResults)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
|
||||
}
|
||||
|
||||
var states = new List<ExpertState>(result.Items.Count);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
ExpertSelectionIdentity? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index] ??
|
||||
throw new ExpertSelectionDataException("The expert search row is missing.");
|
||||
if (item.Source != DataSourceKind.Oracle)
|
||||
{
|
||||
throw new ExpertSelectionDataException("Only Oracle expert identities are selectable.");
|
||||
}
|
||||
|
||||
ValidateExpertIdentity(item.Identity);
|
||||
if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
|
||||
previous is not null && CompareIdentities(previous, item.Identity) > 0)
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"Expert identities must be unique and sorted by name then code.");
|
||||
}
|
||||
|
||||
states.Add(new ExpertState(
|
||||
$"expert-result-{generation:D4}-{index + 1:D4}",
|
||||
item.Identity));
|
||||
previous = item.Identity;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
private static List<ExpertRecommendationPreview> ValidatePreviewResult(
|
||||
ExpertPreviewResult result,
|
||||
ExpertSelectionIdentity expectedIdentity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ValidateExpertIdentity(result.Identity);
|
||||
if (result.Identity != expectedIdentity || result.Items is null ||
|
||||
result.Items.Count > MaximumAccessibleItems ||
|
||||
result.IsTruncated && result.Items.Count != MaximumAccessibleItems)
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert preview response correlation is invalid.");
|
||||
}
|
||||
|
||||
var items = new List<ExpertRecommendationPreview>(result.Items.Count);
|
||||
var indexes = new HashSet<int>();
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var previousIndex = -1;
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
if (item is null || item.PlayIndex is < 0 or > 9_999 ||
|
||||
item.PlayIndex <= previousIndex ||
|
||||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
|
||||
!IsCanonicalText(item.StockName, 200) ||
|
||||
item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
|
||||
decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
|
||||
!indexes.Add(item.PlayIndex) ||
|
||||
!codes.Add(item.StockCode) ||
|
||||
!names.Add(item.StockName))
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"The expert preview contains an invalid or duplicate recommendation.");
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
previousIndex = item.PlayIndex;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void ValidateExpertIdentity(ExpertSelectionIdentity identity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identity);
|
||||
if (identity.ExpertCode.Length != 4 ||
|
||||
identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) ||
|
||||
!IsCanonicalText(identity.ExpertName, 128))
|
||||
{
|
||||
throw new ExpertSelectionDataException("The expert identity is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CompareIdentities(
|
||||
ExpertSelectionIdentity left,
|
||||
ExpertSelectionIdentity right)
|
||||
{
|
||||
var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal);
|
||||
return name != 0
|
||||
? name
|
||||
: string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LegacyExpertResultView ToView(ExpertState state) =>
|
||||
new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName);
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The expert query contains an unsafe character.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(static character => char.IsAsciiLetterOrDigit(character));
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength) =>
|
||||
value is not null && value.Length is > 0 && value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResetPreview()
|
||||
{
|
||||
_previewItems = [];
|
||||
_previewTruncated = false;
|
||||
_previewStatus = LegacyExpertRequestStatus.Idle;
|
||||
_previewError = string.Empty;
|
||||
}
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record ExpertState(
|
||||
string SelectionId,
|
||||
ExpertSelectionIdentity Identity);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyFixedMarket
|
||||
{
|
||||
Overseas,
|
||||
Exchange,
|
||||
Index
|
||||
}
|
||||
|
||||
public enum LegacyFixedCategory
|
||||
{
|
||||
Plate,
|
||||
Market,
|
||||
Chart
|
||||
}
|
||||
|
||||
public sealed record LegacyFixedSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20);
|
||||
|
||||
public sealed record LegacyFixedRuntimeAsset(
|
||||
LegacyFixedMarket Market,
|
||||
string RelativePath,
|
||||
string Sha256);
|
||||
|
||||
public sealed class LegacyFixedAction
|
||||
{
|
||||
internal LegacyFixedAction(
|
||||
string id,
|
||||
LegacyFixedMarket market,
|
||||
string section,
|
||||
string label,
|
||||
string detail,
|
||||
LegacyFixedCategory category,
|
||||
string builderKey,
|
||||
string code,
|
||||
IReadOnlyList<string> aliases,
|
||||
bool available,
|
||||
string prerequisite,
|
||||
LegacyFixedSelection selection,
|
||||
bool dynamicDate,
|
||||
bool dynamicNxtSession)
|
||||
{
|
||||
Id = id;
|
||||
Market = market;
|
||||
Section = section;
|
||||
Label = label;
|
||||
Detail = detail;
|
||||
Category = category;
|
||||
BuilderKey = builderKey;
|
||||
Code = code;
|
||||
Aliases = aliases;
|
||||
Available = available;
|
||||
Prerequisite = prerequisite;
|
||||
Selection = selection;
|
||||
DynamicDate = dynamicDate;
|
||||
DynamicNxtSession = dynamicNxtSession;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public LegacyFixedMarket Market { get; }
|
||||
|
||||
public string Section { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string Detail { get; }
|
||||
|
||||
public LegacyFixedCategory Category { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public bool Available { get; }
|
||||
|
||||
public string Prerequisite { get; }
|
||||
|
||||
public LegacyFixedSelection Selection { get; }
|
||||
|
||||
public bool DynamicDate { get; }
|
||||
|
||||
public bool DynamicNxtSession { get; }
|
||||
|
||||
public bool SupportsMovingAverages =>
|
||||
string.Equals(BuilderKey, "s8010", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record LegacyMaterializedFixedAction(
|
||||
LegacyFixedAction Action,
|
||||
LegacyFixedSelection Selection,
|
||||
LegacyMovingAverageSelection MovingAverages,
|
||||
LegacyFixedRuntimeAsset RuntimeAsset);
|
||||
|
||||
public sealed class LegacyFixedSection
|
||||
{
|
||||
internal LegacyFixedSection(
|
||||
LegacyFixedMarket market,
|
||||
string name,
|
||||
IReadOnlyList<LegacyFixedAction> actions)
|
||||
{
|
||||
Market = market;
|
||||
Name = name;
|
||||
Actions = actions;
|
||||
}
|
||||
|
||||
public LegacyFixedMarket Market { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> Actions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a
|
||||
/// generated build asset; callers can select only by the opaque action id and cannot provide
|
||||
/// builder, cut alias, or scene-selection values.
|
||||
/// </summary>
|
||||
public sealed class LegacyFixedActionCatalog
|
||||
{
|
||||
public const int ExpectedActionCount = 328;
|
||||
public const int ExpectedOverseasCount = 78;
|
||||
public const int ExpectedExchangeCount = 46;
|
||||
public const int ExpectedIndexCount = 204;
|
||||
public const int ExpectedAvailableCount = 322;
|
||||
public const string ExpectedDocumentSha256 =
|
||||
"E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56";
|
||||
public const string ExpectedGeneratorSourceSha256 =
|
||||
"31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5";
|
||||
|
||||
private const string ResourceName =
|
||||
"MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json";
|
||||
private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js";
|
||||
|
||||
private static readonly JsonSerializerOptions StrictJsonOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
|
||||
};
|
||||
|
||||
private static readonly Lazy<LegacyFixedActionCatalog> EmbeddedCatalog =
|
||||
new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private readonly IReadOnlyDictionary<string, LegacyFixedAction> _byId;
|
||||
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>
|
||||
_byMarket;
|
||||
private readonly IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>
|
||||
_sectionsByMarket;
|
||||
private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>> _bySection;
|
||||
|
||||
private LegacyFixedActionCatalog(
|
||||
IReadOnlyList<LegacyFixedAction> actions,
|
||||
IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> runtimeAssets,
|
||||
string documentSha256,
|
||||
string generatorSourceSha256)
|
||||
{
|
||||
Actions = actions;
|
||||
RuntimeAssets = runtimeAssets;
|
||||
DocumentSha256 = documentSha256;
|
||||
GeneratorSourceSha256 = generatorSourceSha256;
|
||||
|
||||
_byId = new ReadOnlyDictionary<string, LegacyFixedAction>(
|
||||
actions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
_byMarket = new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>>(
|
||||
Enum.GetValues<LegacyFixedMarket>().ToDictionary(
|
||||
market => market,
|
||||
market => ReadOnly(actions.Where(action => action.Market == market))));
|
||||
|
||||
var sectionsByMarket = new Dictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>();
|
||||
var bySection = new Dictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>>();
|
||||
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
|
||||
{
|
||||
var sections = _byMarket[market]
|
||||
.GroupBy(action => action.Section, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var sectionActions = ReadOnly(group);
|
||||
bySection.Add((market, group.Key), sectionActions);
|
||||
return new LegacyFixedSection(market, group.Key, sectionActions);
|
||||
});
|
||||
sectionsByMarket.Add(market, ReadOnly(sections));
|
||||
}
|
||||
|
||||
_sectionsByMarket =
|
||||
new ReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedSection>>(
|
||||
sectionsByMarket);
|
||||
_bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section),
|
||||
IReadOnlyList<LegacyFixedAction>>(bySection);
|
||||
}
|
||||
|
||||
public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value;
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> Actions { get; }
|
||||
|
||||
public IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset> RuntimeAssets { get; }
|
||||
|
||||
public string DocumentSha256 { get; }
|
||||
|
||||
public string GeneratorSourceSha256 { get; }
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> GetActions(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return _byMarket[market];
|
||||
}
|
||||
|
||||
public IReadOnlyList<LegacyFixedSection> GetSections(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return _sectionsByMarket[market];
|
||||
}
|
||||
|
||||
public IReadOnlyList<LegacyFixedAction> GetActions(
|
||||
LegacyFixedMarket market,
|
||||
string section)
|
||||
{
|
||||
RequireMarket(market);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(section);
|
||||
return _bySection.TryGetValue((market, section), out var actions)
|
||||
? actions
|
||||
: Array.Empty<LegacyFixedAction>();
|
||||
}
|
||||
|
||||
public LegacyFixedAction? FindAction(string? actionId) =>
|
||||
actionId is not null && _byId.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public LegacyFixedAction GetAction(string actionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return _byId.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'.");
|
||||
}
|
||||
|
||||
public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market)
|
||||
{
|
||||
RequireMarket(market);
|
||||
return RuntimeAssets[market];
|
||||
}
|
||||
|
||||
public LegacyMaterializedFixedAction Materialize(
|
||||
string actionId,
|
||||
DateTimeOffset localNow,
|
||||
LegacyMovingAverageSelection movingAverages = default)
|
||||
{
|
||||
var action = GetAction(actionId);
|
||||
if (!action.Available)
|
||||
{
|
||||
throw new InvalidOperationException(action.Prerequisite);
|
||||
}
|
||||
|
||||
var trustedMovingAverages = action.SupportsMovingAverages
|
||||
? movingAverages
|
||||
: default;
|
||||
var graphicType = action.DynamicNxtSession
|
||||
? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
|
||||
: action.SupportsMovingAverages
|
||||
? MovingAverageText(trustedMovingAverages)
|
||||
: action.Selection.GraphicType;
|
||||
var dataCode = action.DynamicDate
|
||||
? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: action.Selection.DataCode;
|
||||
|
||||
var selection = action.Selection with
|
||||
{
|
||||
GraphicType = graphicType,
|
||||
DataCode = dataCode
|
||||
};
|
||||
return new LegacyMaterializedFixedAction(
|
||||
action,
|
||||
selection,
|
||||
trustedMovingAverages,
|
||||
RuntimeAssets[action.Market]);
|
||||
}
|
||||
|
||||
internal static LegacyFixedActionCatalog LoadForValidation(
|
||||
ReadOnlyMemory<byte> utf8Json,
|
||||
string? expectedDocumentSha256 = null) =>
|
||||
Load(utf8Json.ToArray(), expectedDocumentSha256);
|
||||
|
||||
private static LegacyFixedActionCatalog LoadEmbedded()
|
||||
{
|
||||
using var stream = typeof(LegacyFixedActionCatalog).Assembly
|
||||
.GetManifestResourceStream(ResourceName)
|
||||
?? throw new InvalidDataException(
|
||||
$"The embedded fixed legacy catalog '{ResourceName}' is missing.");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return Load(buffer.ToArray(), ExpectedDocumentSha256);
|
||||
}
|
||||
|
||||
private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(utf8Json);
|
||||
if (utf8Json.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException("The fixed legacy catalog is empty.");
|
||||
}
|
||||
|
||||
CatalogDocumentDto document;
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow
|
||||
});
|
||||
RejectDuplicateProperties(parsed.RootElement, "$root");
|
||||
document = JsonSerializer.Deserialize<CatalogDocumentDto>(utf8Json, StrictJsonOptions)
|
||||
?? throw new InvalidDataException("The fixed legacy catalog root is null.");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception);
|
||||
}
|
||||
|
||||
var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json));
|
||||
if (expectedDocumentSha256 is not null &&
|
||||
!string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}.");
|
||||
}
|
||||
|
||||
ValidateDocumentMetadata(document);
|
||||
var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets);
|
||||
var actions = CreateActions(document.Actions);
|
||||
ValidateCatalogInvariants(actions);
|
||||
return new LegacyFixedActionCatalog(
|
||||
ReadOnly(actions),
|
||||
runtimeAssets,
|
||||
documentHash,
|
||||
document.SourceSha256);
|
||||
}
|
||||
|
||||
private static void ValidateDocumentMetadata(CatalogDocumentDto document)
|
||||
{
|
||||
if (document.SchemaVersion != 1)
|
||||
{
|
||||
throw Invalid("schemaVersion must be 1");
|
||||
}
|
||||
|
||||
if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'");
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
document.SourceSha256,
|
||||
ExpectedGeneratorSourceSha256,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid("the generator source hash is not approved");
|
||||
}
|
||||
|
||||
if (document.RuntimeAssets is null)
|
||||
{
|
||||
throw Invalid("runtimeAssets is required");
|
||||
}
|
||||
|
||||
if (document.Actions is null)
|
||||
{
|
||||
throw Invalid("actions is required");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
|
||||
CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets)
|
||||
{
|
||||
var assets = new Dictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>
|
||||
{
|
||||
[LegacyFixedMarket.Overseas] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Overseas,
|
||||
runtimeAssets.Overseas,
|
||||
"bin/Debug/Res/해외.ini",
|
||||
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
|
||||
[LegacyFixedMarket.Exchange] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Exchange,
|
||||
runtimeAssets.Exchange,
|
||||
"bin/Debug/Res/환율.ini",
|
||||
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
|
||||
[LegacyFixedMarket.Index] = ValidateRuntimeAsset(
|
||||
LegacyFixedMarket.Index,
|
||||
runtimeAssets.Index,
|
||||
"bin/Debug/Res/지수.ini",
|
||||
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
|
||||
};
|
||||
return new ReadOnlyDictionary<LegacyFixedMarket, LegacyFixedRuntimeAsset>(assets);
|
||||
}
|
||||
|
||||
private static LegacyFixedRuntimeAsset ValidateRuntimeAsset(
|
||||
LegacyFixedMarket market,
|
||||
RuntimeAssetDto asset,
|
||||
string expectedPath,
|
||||
string expectedSha256)
|
||||
{
|
||||
if (asset is null ||
|
||||
!string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) ||
|
||||
!string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid");
|
||||
}
|
||||
|
||||
RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256");
|
||||
return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256);
|
||||
}
|
||||
|
||||
private static LegacyFixedAction[] CreateActions(IReadOnlyList<ActionDto> source)
|
||||
{
|
||||
if (source.Count != ExpectedActionCount)
|
||||
{
|
||||
throw Invalid($"exactly {ExpectedActionCount} actions are required");
|
||||
}
|
||||
|
||||
var actions = new LegacyFixedAction[source.Count];
|
||||
for (var index = 0; index < source.Count; index++)
|
||||
{
|
||||
var dto = source[index]
|
||||
?? throw Invalid($"action at index {index} is null");
|
||||
var expectedId = $"fixed-{index + 1:000}";
|
||||
if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"action id at index {index} must be '{expectedId}'");
|
||||
}
|
||||
|
||||
var market = ParseMarket(dto.Market);
|
||||
var category = ParseCategory(dto.Category);
|
||||
var section = RequireText(dto.Section, $"{expectedId}.section");
|
||||
var label = RequireText(dto.Label, $"{expectedId}.label");
|
||||
var detail = RequireText(dto.Detail, $"{expectedId}.detail");
|
||||
var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey");
|
||||
var code = RequireText(dto.Code, $"{expectedId}.code");
|
||||
ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases);
|
||||
var selection = ValidateSelection(expectedId, dto);
|
||||
|
||||
var prerequisite = RequireText(
|
||||
dto.Prerequisite,
|
||||
$"{expectedId}.prerequisite",
|
||||
allowEmpty: true,
|
||||
maximumLength: 512);
|
||||
if (dto.Available != string.IsNullOrEmpty(prerequisite))
|
||||
{
|
||||
throw Invalid($"{expectedId} availability and prerequisite do not agree");
|
||||
}
|
||||
|
||||
actions[index] = new LegacyFixedAction(
|
||||
expectedId,
|
||||
market,
|
||||
section,
|
||||
label,
|
||||
detail,
|
||||
category,
|
||||
builderKey,
|
||||
code,
|
||||
Array.AsReadOnly(dto.Aliases.ToArray()),
|
||||
dto.Available,
|
||||
prerequisite,
|
||||
selection,
|
||||
dto.DynamicDate,
|
||||
dto.DynamicNxtSession);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static void ValidateSceneIdentity(
|
||||
string actionId,
|
||||
string builderKey,
|
||||
string code,
|
||||
IReadOnlyList<string> aliases)
|
||||
{
|
||||
if (aliases is null)
|
||||
{
|
||||
throw Invalid($"{actionId}.aliases is required");
|
||||
}
|
||||
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
|
||||
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))
|
||||
?? throw Invalid($"{actionId} has unknown builder '{builderKey}'");
|
||||
|
||||
if (!definition.CutAliases.Contains(code, StringComparer.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'");
|
||||
}
|
||||
|
||||
if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} must declare exactly its code as its sole alias");
|
||||
}
|
||||
|
||||
var aliasMatches = LegacySceneCatalog.FindByCutAlias(code);
|
||||
if (!aliasMatches.Any(candidate =>
|
||||
string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)))
|
||||
{
|
||||
throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action)
|
||||
{
|
||||
var source = action.Selection
|
||||
?? throw Invalid($"{actionId}.selection is required");
|
||||
var selection = new LegacyFixedSelection(
|
||||
RequireText(source.GroupCode, $"{actionId}.selection.groupCode"),
|
||||
RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true),
|
||||
RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true),
|
||||
RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true),
|
||||
RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true));
|
||||
|
||||
if (action.DynamicDate && action.DynamicNxtSession)
|
||||
{
|
||||
throw Invalid($"{actionId} cannot have two dynamic selection fields");
|
||||
}
|
||||
|
||||
if (action.DynamicDate !=
|
||||
string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} has an invalid dynamic-date declaration");
|
||||
}
|
||||
|
||||
if (action.DynamicNxtSession !=
|
||||
string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{actionId} has an invalid NXT-session declaration");
|
||||
}
|
||||
|
||||
if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrEmpty(selection.GraphicType))
|
||||
{
|
||||
throw Invalid($"{actionId} moving-average flags must be materialized by C#");
|
||||
}
|
||||
|
||||
var values = new[]
|
||||
{
|
||||
selection.GroupCode,
|
||||
selection.Subject,
|
||||
selection.GraphicType,
|
||||
selection.Subtype,
|
||||
selection.DataCode
|
||||
};
|
||||
if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) &&
|
||||
!action.DynamicDate && !action.DynamicNxtSession)
|
||||
{
|
||||
throw Invalid($"{actionId} contains an unapproved selection placeholder");
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
private static void ValidateCatalogInvariants(IReadOnlyList<LegacyFixedAction> actions)
|
||||
{
|
||||
RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount);
|
||||
RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount);
|
||||
RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount);
|
||||
if (actions.Count(action => action.Available) != ExpectedAvailableCount)
|
||||
{
|
||||
throw Invalid($"exactly {ExpectedAvailableCount} actions must be available");
|
||||
}
|
||||
|
||||
if (actions.Take(ExpectedOverseasCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Overseas) ||
|
||||
actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Exchange) ||
|
||||
actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action =>
|
||||
action.Market != LegacyFixedMarket.Index))
|
||||
{
|
||||
throw Invalid("market action ranges are not in the approved legacy order");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireCount(
|
||||
IEnumerable<LegacyFixedAction> actions,
|
||||
LegacyFixedMarket market,
|
||||
int expected)
|
||||
{
|
||||
var actual = actions.Count(action => action.Market == market);
|
||||
if (actual != expected)
|
||||
{
|
||||
throw Invalid(
|
||||
$"market '{MarketText(market)}' must contain {expected} actions, not {actual}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MovingAverageText(LegacyMovingAverageSelection movingAverages)
|
||||
{
|
||||
if (movingAverages.Ma5 && movingAverages.Ma20)
|
||||
{
|
||||
return "MA5,MA20";
|
||||
}
|
||||
|
||||
if (movingAverages.Ma5)
|
||||
{
|
||||
return "MA5";
|
||||
}
|
||||
|
||||
return movingAverages.Ma20 ? "MA20" : string.Empty;
|
||||
}
|
||||
|
||||
private static LegacyFixedMarket ParseMarket(string value) => value switch
|
||||
{
|
||||
"overseas" => LegacyFixedMarket.Overseas,
|
||||
"exchange" => LegacyFixedMarket.Exchange,
|
||||
"index" => LegacyFixedMarket.Index,
|
||||
_ => throw Invalid($"unknown market '{value}'")
|
||||
};
|
||||
|
||||
private static LegacyFixedCategory ParseCategory(string value) => value switch
|
||||
{
|
||||
"plate" => LegacyFixedCategory.Plate,
|
||||
"market" => LegacyFixedCategory.Market,
|
||||
"chart" => LegacyFixedCategory.Chart,
|
||||
_ => throw Invalid($"unknown category '{value}'")
|
||||
};
|
||||
|
||||
private static string MarketText(LegacyFixedMarket market) => market switch
|
||||
{
|
||||
LegacyFixedMarket.Overseas => "overseas",
|
||||
LegacyFixedMarket.Exchange => "exchange",
|
||||
LegacyFixedMarket.Index => "index",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.")
|
||||
};
|
||||
|
||||
private static void RequireMarket(LegacyFixedMarket market)
|
||||
{
|
||||
if (!Enum.IsDefined(market))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireText(
|
||||
string value,
|
||||
string field,
|
||||
bool allowEmpty = false,
|
||||
int maximumLength = 256)
|
||||
{
|
||||
if (value is null || (!allowEmpty && value.Length == 0) ||
|
||||
value.Length > maximumLength || value.Any(char.IsControl) ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw Invalid($"{field} is invalid");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void RequireSha256(string value, string field)
|
||||
{
|
||||
if (value is null || value.Length != 64 || value.Any(character =>
|
||||
!(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F')))
|
||||
{
|
||||
throw Invalid($"{field} is not an uppercase SHA-256 value");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Add(property.Name))
|
||||
{
|
||||
throw new JsonException($"Duplicate property '{property.Name}' at {path}.");
|
||||
}
|
||||
|
||||
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
|
||||
Array.AsReadOnly(source.ToArray());
|
||||
|
||||
private static InvalidDataException Invalid(string message) =>
|
||||
new($"The fixed legacy catalog is invalid: {message}.");
|
||||
|
||||
private sealed class CatalogDocumentDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public required int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedFrom")]
|
||||
public required string GeneratedFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceSha256")]
|
||||
public required string SourceSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeAssets")]
|
||||
public required RuntimeAssetsDto RuntimeAssets { get; init; }
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public required List<ActionDto> Actions { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RuntimeAssetsDto
|
||||
{
|
||||
[JsonPropertyName("overseas")]
|
||||
public required RuntimeAssetDto Overseas { get; init; }
|
||||
|
||||
[JsonPropertyName("exchange")]
|
||||
public required RuntimeAssetDto Exchange { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public required RuntimeAssetDto Index { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RuntimeAssetDto
|
||||
{
|
||||
[JsonPropertyName("relativePath")]
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ActionDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("market")]
|
||||
public required string Market { get; init; }
|
||||
|
||||
[JsonPropertyName("section")]
|
||||
public required string Section { get; init; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public required string Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
[JsonPropertyName("builderKey")]
|
||||
public required string BuilderKey { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
[JsonPropertyName("aliases")]
|
||||
public required List<string> Aliases { get; init; }
|
||||
|
||||
[JsonPropertyName("available")]
|
||||
public required bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("prerequisite")]
|
||||
public required string Prerequisite { get; init; }
|
||||
|
||||
[JsonPropertyName("selection")]
|
||||
public required SelectionDto Selection { get; init; }
|
||||
|
||||
[JsonPropertyName("dynamicDate")]
|
||||
public required bool DynamicDate { get; init; }
|
||||
|
||||
[JsonPropertyName("dynamicNxtSession")]
|
||||
public required bool DynamicNxtSession { get; init; }
|
||||
}
|
||||
|
||||
private sealed class SelectionDto
|
||||
{
|
||||
[JsonPropertyName("groupCode")]
|
||||
public required string GroupCode { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required string Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("graphicType")]
|
||||
public required string GraphicType { get; init; }
|
||||
|
||||
[JsonPropertyName("subtype")]
|
||||
public required string Subtype { get; init; }
|
||||
|
||||
[JsonPropertyName("dataCode")]
|
||||
public required string DataCode { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyIndustryActionKind
|
||||
{
|
||||
Single,
|
||||
Pair,
|
||||
Yield,
|
||||
Candle,
|
||||
FiveRow,
|
||||
Square,
|
||||
Sector
|
||||
}
|
||||
|
||||
public enum LegacyIndustryCandleMode
|
||||
{
|
||||
Price,
|
||||
Volume
|
||||
}
|
||||
|
||||
public sealed record LegacyIndustryRuntimeAsset(
|
||||
IndustryMarket Market,
|
||||
string RelativePath,
|
||||
string Sha256);
|
||||
|
||||
public sealed record LegacyIndustryActionDefinition(
|
||||
IndustryMarket Market,
|
||||
string ActionId,
|
||||
string Label,
|
||||
string Section,
|
||||
string Category,
|
||||
LegacyIndustryActionKind Kind,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
int PageSize,
|
||||
string? YieldPeriod = null,
|
||||
LegacyIndustryCandleMode? CandleMode = null);
|
||||
|
||||
/// <summary>
|
||||
/// Closed projection of the two runtime industry INI files. The WebView supplies an
|
||||
/// action id only; market-specific aliases and page sizes remain authoritative in C#.
|
||||
/// </summary>
|
||||
public static class LegacyIndustryActionCatalog
|
||||
{
|
||||
public const int ActionsPerMarket = 22;
|
||||
|
||||
private static readonly LegacyIndustryRuntimeAsset KospiAsset = new(
|
||||
IndustryMarket.Kospi,
|
||||
"bin/Debug/Res/업종_코스피.ini",
|
||||
"F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52");
|
||||
|
||||
private static readonly LegacyIndustryRuntimeAsset KosdaqAsset = new(
|
||||
IndustryMarket.Kosdaq,
|
||||
"bin/Debug/Res/업종_코스닥.ini",
|
||||
"F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4");
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KospiActions =
|
||||
CreateActions(IndustryMarket.Kospi);
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> KosdaqActions =
|
||||
CreateActions(IndustryMarket.Kosdaq);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
|
||||
KospiActionsById = CreateLookup(KospiActions);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyIndustryActionDefinition>
|
||||
KosdaqActionsById = CreateLookup(KosdaqActions);
|
||||
|
||||
public static LegacyIndustryRuntimeAsset GetRuntimeAsset(IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiAsset,
|
||||
IndustryMarket.Kosdaq => KosdaqAsset,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
public static IReadOnlyList<LegacyIndustryActionDefinition> GetActions(
|
||||
IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiActions,
|
||||
IndustryMarket.Kosdaq => KosdaqActions,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
public static bool TryGetAction(
|
||||
IndustryMarket market,
|
||||
string actionId,
|
||||
out LegacyIndustryActionDefinition? definition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (!IsSafeActionId(actionId))
|
||||
{
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return LookupFor(market).TryGetValue(actionId, out definition);
|
||||
}
|
||||
|
||||
public static LegacyIndustryActionDefinition GetRequiredAction(
|
||||
IndustryMarket market,
|
||||
string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
return IsSafeActionId(actionId) &&
|
||||
LookupFor(market).TryGetValue(actionId, out var definition)
|
||||
? definition
|
||||
: throw new KeyNotFoundException("The industry action id is not registered.");
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> LookupFor(
|
||||
IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => KospiActionsById,
|
||||
IndustryMarket.Kosdaq => KosdaqActionsById,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.")
|
||||
};
|
||||
|
||||
private static bool IsSafeActionId(string actionId) =>
|
||||
actionId.Length is > 0 and <= 64 &&
|
||||
!actionId.Any(char.IsControl) &&
|
||||
string.Equals(actionId, actionId.Trim(), StringComparison.Ordinal);
|
||||
|
||||
private static IReadOnlyList<LegacyIndustryActionDefinition> CreateActions(
|
||||
IndustryMarket market)
|
||||
{
|
||||
var pairAlias = market == IndustryMarket.Kospi ? "8018" : "8032";
|
||||
var squareAlias = market == IndustryMarket.Kospi ? "8001" : "8002";
|
||||
var actions = new List<LegacyIndustryActionDefinition>(ActionsPerMarket)
|
||||
{
|
||||
Action(market, "one-column", "1열판", "업종 판", "plate",
|
||||
LegacyIndustryActionKind.Single, "s5001", "5001"),
|
||||
Action(market, "two-column", "2열판", "업종 판", "plate",
|
||||
LegacyIndustryActionKind.Pair, "s8018", pairAlias)
|
||||
};
|
||||
|
||||
AddYield(actions, market, "5일", "FiveDays");
|
||||
AddYield(actions, market, "20일", "TwentyDays");
|
||||
AddYield(actions, market, "60일", "SixtyDays");
|
||||
AddYield(actions, market, "120일", "OneHundredTwentyDays");
|
||||
AddYield(actions, market, "240일", "TwoHundredFortyDays");
|
||||
|
||||
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Price);
|
||||
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Price);
|
||||
|
||||
AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Volume);
|
||||
AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Volume);
|
||||
|
||||
actions.Add(Action(market, "five-row", "5단 표그래프", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.FiveRow, "s5074", "5074", pageSize: 5));
|
||||
actions.Add(Action(market, "square", "네모그래프", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.Square, "s8001", squareAlias));
|
||||
actions.Add(Action(market, "sector", "섹터지수", "시장 전체", "plate",
|
||||
LegacyIndustryActionKind.Sector, "s5078", "5078"));
|
||||
|
||||
Validate(actions, market);
|
||||
return Array.AsReadOnly(actions.ToArray());
|
||||
}
|
||||
|
||||
private static void AddYield(
|
||||
ICollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market,
|
||||
string periodLabel,
|
||||
string period)
|
||||
{
|
||||
actions.Add(Action(
|
||||
market,
|
||||
$"yield-{periodLabel}",
|
||||
$"수익률그래프_{periodLabel}",
|
||||
"수익률 그래프",
|
||||
"chart",
|
||||
LegacyIndustryActionKind.Yield,
|
||||
"s5086",
|
||||
"5086",
|
||||
yieldPeriod: period));
|
||||
}
|
||||
|
||||
private static void AddCandle(
|
||||
ICollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market,
|
||||
string periodLabel,
|
||||
string sceneAlias,
|
||||
LegacyIndustryCandleMode mode)
|
||||
{
|
||||
var volume = mode == LegacyIndustryCandleMode.Volume;
|
||||
actions.Add(Action(
|
||||
market,
|
||||
$"candle-{(volume ? "volume-" : string.Empty)}{periodLabel}",
|
||||
$"캔들그래프{(volume ? "(거래량)" : string.Empty)}_{periodLabel}",
|
||||
volume ? "캔들 그래프 · 거래량" : "캔들 그래프",
|
||||
"chart",
|
||||
LegacyIndustryActionKind.Candle,
|
||||
"s8010",
|
||||
sceneAlias,
|
||||
candleMode: mode));
|
||||
}
|
||||
|
||||
private static LegacyIndustryActionDefinition Action(
|
||||
IndustryMarket market,
|
||||
string actionId,
|
||||
string label,
|
||||
string section,
|
||||
string category,
|
||||
LegacyIndustryActionKind kind,
|
||||
string builderKey,
|
||||
string sceneAlias,
|
||||
int pageSize = 0,
|
||||
string? yieldPeriod = null,
|
||||
LegacyIndustryCandleMode? candleMode = null) => new(
|
||||
market,
|
||||
actionId,
|
||||
label,
|
||||
section,
|
||||
category,
|
||||
kind,
|
||||
builderKey,
|
||||
sceneAlias,
|
||||
pageSize,
|
||||
yieldPeriod,
|
||||
candleMode);
|
||||
|
||||
private static IReadOnlyDictionary<string, LegacyIndustryActionDefinition> CreateLookup(
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> actions) =>
|
||||
new System.Collections.ObjectModel.ReadOnlyDictionary<
|
||||
string,
|
||||
LegacyIndustryActionDefinition>(actions.ToDictionary(
|
||||
action => action.ActionId,
|
||||
StringComparer.Ordinal));
|
||||
|
||||
private static void Validate(
|
||||
IReadOnlyCollection<LegacyIndustryActionDefinition> actions,
|
||||
IndustryMarket market)
|
||||
{
|
||||
if (actions.Count != ActionsPerMarket ||
|
||||
actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count() !=
|
||||
ActionsPerMarket ||
|
||||
actions.Any(action => action.Market != market) ||
|
||||
actions.Any(action => action.PageSize is not (0 or 5)) ||
|
||||
actions.Any(action =>
|
||||
(action.Kind == LegacyIndustryActionKind.FiveRow) != (action.PageSize == 5)) ||
|
||||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Yield &&
|
||||
string.IsNullOrEmpty(action.YieldPeriod)) ||
|
||||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Yield &&
|
||||
action.YieldPeriod is not null) ||
|
||||
actions.Any(action => action.Kind == LegacyIndustryActionKind.Candle &&
|
||||
action.CandleMode is null) ||
|
||||
actions.Any(action => action.Kind != LegacyIndustryActionKind.Candle &&
|
||||
action.CandleMode is not null) ||
|
||||
actions.Any(action => !IsSafeActionId(action.ActionId)) ||
|
||||
actions.Any(action => !LegacySceneCatalog.FindByCutAlias(action.SceneAlias)
|
||||
.Any(scene =>
|
||||
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
|
||||
(int)scene.PageSize == action.PageSize)))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy industry action catalog is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public sealed record LegacyIndustrySelectionSnapshot(
|
||||
long Revision,
|
||||
IndustryMarket? Market,
|
||||
IReadOnlyList<IndustrySelection> Industries,
|
||||
IReadOnlyList<LegacyIndustryActionDefinition> Actions,
|
||||
int? SelectedIndex,
|
||||
IndustrySelection? SelectedIndustry,
|
||||
IndustrySelection? FirstIndustry,
|
||||
IndustrySelection? SecondIndustry);
|
||||
|
||||
public sealed record LegacyIndustrySceneSelectionDraft(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
/// <summary>
|
||||
/// Safe, generic playlist fields produced entirely from the C# catalog and the
|
||||
/// authoritative UC2 selection state. It intentionally has no caller-supplied row id.
|
||||
/// </summary>
|
||||
public sealed record LegacyIndustryPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
int PageSize,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
IndustryMarket Market,
|
||||
LegacyIndustrySceneSelectionDraft Selection,
|
||||
string RuntimeAssetRelativePath,
|
||||
string RuntimeAssetSha256,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
|
||||
public sealed class LegacyIndustryWorkflowException : Exception
|
||||
{
|
||||
public LegacyIndustryWorkflowException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the legacy UC2 selector and the UC1 industry actions.
|
||||
/// </summary>
|
||||
public sealed class LegacyIndustrySelectionWorkflow
|
||||
{
|
||||
private const int DefaultFadeDuration = 6;
|
||||
|
||||
private static readonly IReadOnlyList<IndustrySelection> NoIndustries =
|
||||
Array.AsReadOnly(Array.Empty<IndustrySelection>());
|
||||
|
||||
private static readonly IReadOnlyList<LegacyIndustryActionDefinition> NoActions =
|
||||
Array.AsReadOnly(Array.Empty<LegacyIndustryActionDefinition>());
|
||||
|
||||
private readonly IIndustrySelectionService _selectionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
||||
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
||||
private IndustryMarket? _market;
|
||||
private int? _selectedIndex;
|
||||
private IndustrySelection? _selectedIndustry;
|
||||
private IndustrySelection? _firstIndustry;
|
||||
private IndustrySelection? _secondIndustry;
|
||||
private long _revision;
|
||||
private long _loadGeneration;
|
||||
|
||||
public LegacyIndustrySelectionWorkflow(
|
||||
IIndustrySelectionService selectionService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyIndustrySelectionSnapshot> LoadMarketAsync(
|
||||
IndustryMarket market,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateMarket(market);
|
||||
var generation = Interlocked.Increment(ref _loadGeneration);
|
||||
var serviceRows = await _selectionService.GetAsync(market, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// A slower previous request must never replace a newer market request.
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var normalizedRows = NormalizeRows(serviceRows, market);
|
||||
if (generation != Volatile.Read(ref _loadGeneration))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var changingMarket = _market != market;
|
||||
var previousSelected = changingMarket ? null : _selectedIndustry;
|
||||
var previousFirst = changingMarket ? null : _firstIndustry;
|
||||
var previousSecond = changingMarket ? null : _secondIndustry;
|
||||
|
||||
_market = market;
|
||||
_industries = normalizedRows;
|
||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
||||
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
||||
_selectedIndex = _selectedIndustry is null
|
||||
? null
|
||||
: FindIdentityIndex(normalizedRows, _selectedIndustry);
|
||||
_firstIndustry = Reconcile(previousFirst, normalizedRows);
|
||||
_secondIndustry = Reconcile(previousSecond, normalizedRows);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SelectIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index)
|
||||
{
|
||||
if (!TryGetIndustry(index, out var industry))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = index;
|
||||
_selectedIndustry = industry;
|
||||
|
||||
// UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
|
||||
// double-clicked item to txt1.
|
||||
_secondIndustry = _firstIndustry;
|
||||
_firstIndustry = industry;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SwapIndustries()
|
||||
{
|
||||
if (_firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot ClearSelections()
|
||||
{
|
||||
if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_selectedIndex = null;
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
_secondIndustry = null;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
if (_market is not { } market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("Select an industry market first.");
|
||||
}
|
||||
|
||||
if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) ||
|
||||
definition is null)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry action is not registered.");
|
||||
}
|
||||
|
||||
var projection = ProjectSelection(definition, market);
|
||||
var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market);
|
||||
return new LegacyIndustryPlaylistDraft(
|
||||
definition.ActionId,
|
||||
definition.BuilderKey,
|
||||
definition.SceneAlias,
|
||||
definition.PageSize,
|
||||
projection.Title,
|
||||
definition.Label,
|
||||
definition.Category,
|
||||
market,
|
||||
projection.Selection,
|
||||
asset.RelativePath,
|
||||
asset.Sha256,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
}
|
||||
|
||||
private IndustryProjection ProjectSelection(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
IndustryMarket market)
|
||||
{
|
||||
var industryGroup = market == IndustryMarket.Kospi
|
||||
? "INDUSTRY_KOSPI"
|
||||
: "INDUSTRY_KOSDAQ";
|
||||
var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ";
|
||||
|
||||
return definition.Kind switch
|
||||
{
|
||||
LegacyIndustryActionKind.Single => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"1열판",
|
||||
"CURRENT"),
|
||||
LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup),
|
||||
LegacyIndustryActionKind.Yield => ForSelected(
|
||||
definition,
|
||||
industryGroup,
|
||||
"YIELD_GRAPH",
|
||||
definition.YieldPeriod!),
|
||||
LegacyIndustryActionKind.Candle => ForSelected(
|
||||
definition,
|
||||
market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY",
|
||||
string.Empty,
|
||||
definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"),
|
||||
LegacyIndustryActionKind.FiveRow => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
market == IndustryMarket.Kospi
|
||||
? "PAGED_DOMESTIC_KOSPI"
|
||||
: "PAGED_DOMESTIC_KOSDAQ",
|
||||
"INDEX",
|
||||
string.Empty,
|
||||
"INDUSTRY",
|
||||
CurrentLocalDate())),
|
||||
LegacyIndustryActionKind.Square => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"네모그래프",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
LegacyIndustryActionKind.Sector => new IndustryProjection(
|
||||
$"{MarketLabel(market)} {definition.Label}",
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
industryGroup,
|
||||
marketName,
|
||||
"SECTOR_INDEX",
|
||||
string.Empty,
|
||||
string.Empty)),
|
||||
_ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.")
|
||||
};
|
||||
}
|
||||
|
||||
private IndustryProjection ForSelected(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode,
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
if (_selectedIndustry is not { } selected)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires one selected industry.");
|
||||
}
|
||||
|
||||
return new IndustryProjection(
|
||||
selected.IndustryName,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
selected.IndustryName,
|
||||
graphicType,
|
||||
subtype,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private IndustryProjection ForPair(
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode)
|
||||
{
|
||||
if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
|
||||
first.IndustryName.Contains('-', StringComparison.Ordinal) ||
|
||||
second.IndustryName.Contains('-', StringComparison.Ordinal))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires two delimiter-safe industries.");
|
||||
}
|
||||
|
||||
var subject = $"{first.IndustryName}-{second.IndustryName}";
|
||||
return new IndustryProjection(
|
||||
subject,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
groupCode,
|
||||
subject,
|
||||
"2열판",
|
||||
string.Empty,
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
private string CurrentLocalDate()
|
||||
{
|
||||
var local = _timeProvider.GetLocalNow();
|
||||
return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private bool TryGetIndustry(int index, out IndustrySelection industry)
|
||||
{
|
||||
if (index < 0 || index >= _industries.Count)
|
||||
{
|
||||
industry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
industry = _industries[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IndustrySelection> NormalizeRows(
|
||||
IReadOnlyList<IndustrySelection>? rows,
|
||||
IndustryMarket market)
|
||||
{
|
||||
if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry list is invalid.");
|
||||
}
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var normalized = new IndustrySelection[rows.Count];
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index];
|
||||
if (row is null || row.Market != market)
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains a cross-market row.");
|
||||
}
|
||||
|
||||
var name = row.IndustryName?.Trim();
|
||||
var code = row.IndustryCode?.Trim();
|
||||
if (string.IsNullOrEmpty(name) ||
|
||||
name.Length > LegacyIndustrySelectionService.MaximumNameLength ||
|
||||
name.Any(char.IsControl) ||
|
||||
string.IsNullOrEmpty(code) ||
|
||||
code.Length > 32 ||
|
||||
code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
|
||||
!names.Add(name) ||
|
||||
!codes.Add(code))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains an unsafe or ambiguous row.");
|
||||
}
|
||||
|
||||
normalized[index] = new IndustrySelection(market, name, code);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(normalized);
|
||||
}
|
||||
|
||||
private static IndustrySelection? Reconcile(
|
||||
IndustrySelection? previous,
|
||||
IReadOnlyList<IndustrySelection> rows)
|
||||
{
|
||||
if (previous is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rows.FirstOrDefault(row => SameIdentity(row, previous));
|
||||
}
|
||||
|
||||
private static int? FindIdentityIndex(
|
||||
IReadOnlyList<IndustrySelection> rows,
|
||||
IndustrySelection selected)
|
||||
{
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
if (SameIdentity(rows[index], selected))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool SameIdentity(IndustrySelection left, IndustrySelection right) =>
|
||||
left.Market == right.Market &&
|
||||
string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) &&
|
||||
string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal);
|
||||
|
||||
private static void ValidateMarket(IndustryMarket market)
|
||||
{
|
||||
if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(market),
|
||||
market,
|
||||
"Unsupported industry market.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MarketLabel(IndustryMarket market) => market switch
|
||||
{
|
||||
IndustryMarket.Kospi => "코스피",
|
||||
IndustryMarket.Kosdaq => "코스닥",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market), market, null)
|
||||
};
|
||||
|
||||
private LegacyIndustrySelectionSnapshot CreateSnapshot() => new(
|
||||
_revision,
|
||||
_market,
|
||||
_industries,
|
||||
_actions,
|
||||
_selectedIndex,
|
||||
_selectedIndustry,
|
||||
_firstIndustry,
|
||||
_secondIndustry);
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
private sealed record IndustryProjection(
|
||||
string Title,
|
||||
LegacyIndustrySceneSelectionDraft Selection);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOperatorPlayoutPhase
|
||||
{
|
||||
Idle,
|
||||
Prepared,
|
||||
Program
|
||||
}
|
||||
|
||||
public enum LegacyOperatorPlayoutCommand
|
||||
{
|
||||
Prepare,
|
||||
TakeIn,
|
||||
Next,
|
||||
TakeOut
|
||||
}
|
||||
|
||||
public sealed record LegacyOperatorPlayoutRequest(
|
||||
IReadOnlyList<LegacyPlaylistEntry> Playlist,
|
||||
int SelectedIndexZeroBased);
|
||||
|
||||
public sealed record LegacyOperatorPlayoutSnapshot(
|
||||
PlayoutMode Mode,
|
||||
PlayoutConnectionState ConnectionState,
|
||||
LegacyOperatorPlayoutPhase Phase,
|
||||
bool IsProcessRunning,
|
||||
bool IsConnected,
|
||||
bool IsCommandAvailable,
|
||||
bool LiveTakeInAllowed,
|
||||
bool IsPlayCompletionPending,
|
||||
bool OutcomeUnknown,
|
||||
string? PreparedCode,
|
||||
string? OnAirCode,
|
||||
int CurrentCueIndexZeroBased,
|
||||
string? CurrentEntryId,
|
||||
string? BuilderKey,
|
||||
int PageIndexZeroBased,
|
||||
int PageCount,
|
||||
int PageSize,
|
||||
int ItemCount,
|
||||
int CurrentPageItemCount,
|
||||
bool IsLastPage,
|
||||
LegacyWorkflowNextKind NextKind,
|
||||
bool IsBusy,
|
||||
bool RefreshActive,
|
||||
int RefreshCompletedCount,
|
||||
int? RefreshMaximumCount,
|
||||
bool RefreshLimitReached,
|
||||
int FadeDuration,
|
||||
bool BackgroundEnabled,
|
||||
string BackgroundFileName,
|
||||
bool CanChangeBackground,
|
||||
string Message);
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOverseasActionSource
|
||||
{
|
||||
FixedIndex,
|
||||
Industry,
|
||||
Stock
|
||||
}
|
||||
|
||||
public enum LegacyOverseasActionKind
|
||||
{
|
||||
Current,
|
||||
Candle
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasFixedIndexTarget
|
||||
{
|
||||
internal LegacyOverseasFixedIndexTarget(
|
||||
string targetId,
|
||||
string displayName,
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nationCode)
|
||||
{
|
||||
TargetId = targetId;
|
||||
DisplayName = displayName;
|
||||
InputName = inputName;
|
||||
Symbol = symbol;
|
||||
NationCode = nationCode;
|
||||
}
|
||||
|
||||
public string TargetId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string InputName { get; }
|
||||
|
||||
public string Symbol { get; }
|
||||
|
||||
public string NationCode { get; }
|
||||
|
||||
public string ForeignDomesticCode => "0";
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasActionDefinition
|
||||
{
|
||||
internal LegacyOverseasActionDefinition(
|
||||
string actionId,
|
||||
string label,
|
||||
LegacyOverseasActionSource source,
|
||||
LegacyOverseasActionKind kind,
|
||||
string builderKey,
|
||||
string sceneAlias,
|
||||
int? periodDays)
|
||||
{
|
||||
ActionId = actionId;
|
||||
Label = label;
|
||||
Source = source;
|
||||
Kind = kind;
|
||||
BuilderKey = builderKey;
|
||||
SceneAlias = sceneAlias;
|
||||
Aliases = Array.AsReadOnly([sceneAlias]);
|
||||
PeriodDays = periodDays;
|
||||
}
|
||||
|
||||
public string ActionId { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public LegacyOverseasActionSource Source { get; }
|
||||
|
||||
public LegacyOverseasActionKind Kind { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string SceneAlias { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public int PageSize => 0;
|
||||
|
||||
public int? PeriodDays { get; }
|
||||
|
||||
public bool SupportsMovingAverages => Kind == LegacyOverseasActionKind.Candle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed UC5 metadata. The original control exposed ten fixed world indices and twelve
|
||||
/// buttons: five index candles, one US-industry current plate, and six US/TW stock actions.
|
||||
/// Scene identities never come from Web input.
|
||||
/// </summary>
|
||||
public static class LegacyOverseasActionCatalog
|
||||
{
|
||||
public const int FixedIndexTargetCount = 10;
|
||||
public const int ActionCount = 12;
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasFixedIndexTarget> RegisteredTargets =
|
||||
Array.AsReadOnly<LegacyOverseasFixedIndexTarget>(
|
||||
[
|
||||
Target("dow-jones", "다우존스", "DJI@DJI", "US"),
|
||||
Target("nasdaq", "나스닥", "NAS@IXIC", "US"),
|
||||
Target("sp500", "S&P500", "SPI@SPX", "US"),
|
||||
Target("germany-dax-30", "독일 DAX 30", "XTR@DAX30", "DE"),
|
||||
Target("united-kingdom-ftse-100", "영국 FTSE 100", "LNS@FTSE100", "GB"),
|
||||
Target("france-cac-40", "프랑스 CAC 40", "PAS@CAC40", "FR"),
|
||||
Target("japan-nikkei", "일본 니케이", "NII@NI225", "JP"),
|
||||
Target("china-shanghai-composite", "중국 상해종합", "SHS@000001", "CN"),
|
||||
Target("hong-kong-hang-seng", "홍콩 항셍", "HSI@HSI", "HK"),
|
||||
Target("taiwan-weighted", "대만 가권", "TWS@TI01", "TW")
|
||||
]);
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasActionDefinition> RegisteredActions =
|
||||
Array.AsReadOnly(CreateActions());
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>
|
||||
TargetsById = new ReadOnlyDictionary<string, LegacyOverseasFixedIndexTarget>(
|
||||
RegisteredTargets.ToDictionary(target => target.TargetId, StringComparer.Ordinal));
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LegacyOverseasActionDefinition>
|
||||
ActionsById = new ReadOnlyDictionary<string, LegacyOverseasActionDefinition>(
|
||||
RegisteredActions.ToDictionary(action => action.ActionId, StringComparer.Ordinal));
|
||||
|
||||
static LegacyOverseasActionCatalog()
|
||||
{
|
||||
ValidateTargets();
|
||||
ValidateActions();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets =>
|
||||
RegisteredTargets;
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyOverseasActionDefinition> GetActions(
|
||||
LegacyOverseasActionSource source)
|
||||
{
|
||||
if (!Enum.IsDefined(source))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(source), source, "Unknown overseas source.");
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(
|
||||
RegisteredActions.Where(action => action.Source == source).ToArray());
|
||||
}
|
||||
|
||||
public static LegacyOverseasFixedIndexTarget? FindFixedIndexTarget(string? targetId) =>
|
||||
targetId is not null && TargetsById.TryGetValue(targetId, out var target)
|
||||
? target
|
||||
: null;
|
||||
|
||||
public static LegacyOverseasFixedIndexTarget GetFixedIndexTarget(string targetId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
||||
return TargetsById.TryGetValue(targetId, out var target)
|
||||
? target
|
||||
: throw new KeyNotFoundException("The overseas fixed-index target is not registered.");
|
||||
}
|
||||
|
||||
public static LegacyOverseasActionDefinition? FindAction(string? actionId) =>
|
||||
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public static LegacyOverseasActionDefinition GetAction(string actionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: throw new KeyNotFoundException("The overseas action is not registered.");
|
||||
}
|
||||
|
||||
private static LegacyOverseasFixedIndexTarget Target(
|
||||
string targetId,
|
||||
string inputName,
|
||||
string symbol,
|
||||
string nationCode) =>
|
||||
new(targetId, inputName, inputName, symbol, nationCode);
|
||||
|
||||
private static LegacyOverseasActionDefinition[] CreateActions() =>
|
||||
[
|
||||
Candle("index-candle-5d", "해외지수 캔들 5일", LegacyOverseasActionSource.FixedIndex, 5, "8061"),
|
||||
Candle("index-candle-20d", "해외지수 캔들 20일", LegacyOverseasActionSource.FixedIndex, 20, "8040"),
|
||||
Candle("index-candle-60d", "해외지수 캔들 60일", LegacyOverseasActionSource.FixedIndex, 60, "8046"),
|
||||
Candle("index-candle-120d", "해외지수 캔들 120일", LegacyOverseasActionSource.FixedIndex, 120, "8051"),
|
||||
Candle("index-candle-240d", "해외지수 캔들 240일", LegacyOverseasActionSource.FixedIndex, 240, "8056"),
|
||||
new(
|
||||
"industry-current",
|
||||
"미국 해외업종 현재가 1열판",
|
||||
LegacyOverseasActionSource.Industry,
|
||||
LegacyOverseasActionKind.Current,
|
||||
"s5001",
|
||||
"5001",
|
||||
periodDays: null),
|
||||
new(
|
||||
"stock-current",
|
||||
"해외종목 현재가 1열판",
|
||||
LegacyOverseasActionSource.Stock,
|
||||
LegacyOverseasActionKind.Current,
|
||||
"s5001",
|
||||
"5001",
|
||||
periodDays: null),
|
||||
Candle("stock-candle-5d", "해외종목 캔들 5일", LegacyOverseasActionSource.Stock, 5, "8061"),
|
||||
Candle("stock-candle-20d", "해외종목 캔들 20일", LegacyOverseasActionSource.Stock, 20, "8040"),
|
||||
Candle("stock-candle-60d", "해외종목 캔들 60일", LegacyOverseasActionSource.Stock, 60, "8046"),
|
||||
Candle("stock-candle-120d", "해외종목 캔들 120일", LegacyOverseasActionSource.Stock, 120, "8051"),
|
||||
Candle("stock-candle-240d", "해외종목 캔들 240일", LegacyOverseasActionSource.Stock, 240, "8056")
|
||||
];
|
||||
|
||||
private static LegacyOverseasActionDefinition Candle(
|
||||
string actionId,
|
||||
string label,
|
||||
LegacyOverseasActionSource source,
|
||||
int periodDays,
|
||||
string sceneAlias) =>
|
||||
new(
|
||||
actionId,
|
||||
label,
|
||||
source,
|
||||
LegacyOverseasActionKind.Candle,
|
||||
"s8010",
|
||||
sceneAlias,
|
||||
periodDays);
|
||||
|
||||
private static void ValidateTargets()
|
||||
{
|
||||
if (RegisteredTargets.Count != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.TargetId)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.DisplayName)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Select(target => target.Symbol)
|
||||
.Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount ||
|
||||
RegisteredTargets.Any(target =>
|
||||
!IsSafeId(target.TargetId) ||
|
||||
!IsSafeCanonicalText(target.DisplayName, 200) ||
|
||||
!string.Equals(target.DisplayName, target.InputName, StringComparison.Ordinal) ||
|
||||
!IsSafeSymbol(target.Symbol) ||
|
||||
target.NationCode.Length != 2 ||
|
||||
target.NationCode.Any(character => character is < 'A' or > 'Z')))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy overseas fixed-index catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateActions()
|
||||
{
|
||||
if (RegisteredActions.Count != ActionCount ||
|
||||
RegisteredActions.Select(action => action.ActionId)
|
||||
.Distinct(StringComparer.Ordinal).Count() != ActionCount ||
|
||||
RegisteredActions.Any(action => !IsSafeId(action.ActionId)) ||
|
||||
RegisteredActions.Any(action => action.PageSize != 0) ||
|
||||
RegisteredActions.Any(action =>
|
||||
action.Kind == LegacyOverseasActionKind.Candle != action.PeriodDays.HasValue) ||
|
||||
RegisteredActions.Any(action =>
|
||||
action.Source == LegacyOverseasActionSource.Industry &&
|
||||
action.Kind != LegacyOverseasActionKind.Current) ||
|
||||
RegisteredActions.Any(action =>
|
||||
!LegacySceneCatalog.FindByCutAlias(action.SceneAlias).Any(scene =>
|
||||
string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) &&
|
||||
(int)scene.PageSize == action.PageSize)))
|
||||
{
|
||||
throw new InvalidOperationException("The legacy overseas action catalog is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSafeId(string value) =>
|
||||
value.Length is > 0 and <= 64 &&
|
||||
value.All(character => char.IsAsciiLetterOrDigit(character) || character == '-');
|
||||
|
||||
private static bool IsSafeCanonicalText(string value, int maximumLength) =>
|
||||
value.Length is > 0 &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Any(character => char.IsControl(character) || char.IsSurrogate(character));
|
||||
|
||||
private static bool IsSafeSymbol(string value) =>
|
||||
IsSafeCanonicalText(value, 64) &&
|
||||
value.All(character =>
|
||||
char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
using System.Globalization;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyOverseasSearchStatus
|
||||
{
|
||||
Idle,
|
||||
Loading,
|
||||
Ready,
|
||||
Error
|
||||
}
|
||||
|
||||
public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20);
|
||||
|
||||
public sealed class LegacyOverseasIndustrySearchRow
|
||||
{
|
||||
internal LegacyOverseasIndustrySearchRow(
|
||||
string selectionId,
|
||||
OverseasIndustryIndexItem identity)
|
||||
{
|
||||
SelectionId = selectionId;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
public string SelectionId { get; }
|
||||
|
||||
public string KoreanName => Identity.KoreanName;
|
||||
|
||||
public string InputName => Identity.InputName;
|
||||
|
||||
public string Symbol => Identity.Symbol;
|
||||
|
||||
public string NationCode => "US";
|
||||
|
||||
public string ForeignDomesticCode => "0";
|
||||
|
||||
internal OverseasIndustryIndexItem Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasStockSearchRow
|
||||
{
|
||||
internal LegacyOverseasStockSearchRow(
|
||||
string selectionId,
|
||||
WorldStockSearchItem identity)
|
||||
{
|
||||
SelectionId = selectionId;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
public string SelectionId { get; }
|
||||
|
||||
public string InputName => Identity.InputName;
|
||||
|
||||
public string Symbol => Identity.Symbol;
|
||||
|
||||
public string NationCode => Identity.NationCode;
|
||||
|
||||
public string ForeignDomesticCode => "1";
|
||||
|
||||
internal WorldStockSearchItem Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasIndustrySearchSnapshot
|
||||
{
|
||||
internal LegacyOverseasIndustrySearchSnapshot(
|
||||
LegacyOverseasSearchStatus status,
|
||||
string query,
|
||||
DateTimeOffset? retrievedAt,
|
||||
IReadOnlyList<LegacyOverseasIndustrySearchRow> results,
|
||||
bool isTruncated,
|
||||
LegacyOverseasIndustrySearchRow? selected,
|
||||
string error)
|
||||
{
|
||||
Status = status;
|
||||
Query = query;
|
||||
RetrievedAt = retrievedAt;
|
||||
Results = results;
|
||||
IsTruncated = isTruncated;
|
||||
Selected = selected;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LegacyOverseasSearchStatus Status { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public DateTimeOffset? RetrievedAt { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasIndustrySearchRow> Results { get; }
|
||||
|
||||
public int ResultCount => Results.Count;
|
||||
|
||||
public bool IsTruncated { get; }
|
||||
|
||||
public LegacyOverseasIndustrySearchRow? Selected { get; }
|
||||
|
||||
public string? SelectedId => Selected?.SelectionId;
|
||||
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasStockSearchSnapshot
|
||||
{
|
||||
internal LegacyOverseasStockSearchSnapshot(
|
||||
LegacyOverseasSearchStatus status,
|
||||
string query,
|
||||
DateTimeOffset? retrievedAt,
|
||||
IReadOnlyList<LegacyOverseasStockSearchRow> results,
|
||||
bool isTruncated,
|
||||
LegacyOverseasStockSearchRow? selected,
|
||||
string error)
|
||||
{
|
||||
Status = status;
|
||||
Query = query;
|
||||
RetrievedAt = retrievedAt;
|
||||
Results = results;
|
||||
IsTruncated = isTruncated;
|
||||
Selected = selected;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LegacyOverseasSearchStatus Status { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public DateTimeOffset? RetrievedAt { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasStockSearchRow> Results { get; }
|
||||
|
||||
public int ResultCount => Results.Count;
|
||||
|
||||
public bool IsTruncated { get; }
|
||||
|
||||
public LegacyOverseasStockSearchRow? Selected { get; }
|
||||
|
||||
public string? SelectedId => Selected?.SelectionId;
|
||||
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOverseasWorkflowSnapshot
|
||||
{
|
||||
internal LegacyOverseasWorkflowSnapshot(
|
||||
long revision,
|
||||
IReadOnlyList<LegacyOverseasFixedIndexTarget> fixedIndexTargets,
|
||||
LegacyOverseasFixedIndexTarget? selectedFixedIndex,
|
||||
LegacyOverseasIndustrySearchSnapshot industry,
|
||||
LegacyOverseasStockSearchSnapshot stock,
|
||||
LegacyOverseasMovingAverageSelection movingAverages,
|
||||
IReadOnlyList<LegacyOverseasActionDefinition> actions)
|
||||
{
|
||||
Revision = revision;
|
||||
FixedIndexTargets = fixedIndexTargets;
|
||||
SelectedFixedIndex = selectedFixedIndex;
|
||||
Industry = industry;
|
||||
Stock = stock;
|
||||
MovingAverages = movingAverages;
|
||||
Actions = actions;
|
||||
}
|
||||
|
||||
public long Revision { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets { get; }
|
||||
|
||||
public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; }
|
||||
|
||||
public LegacyOverseasIndustrySearchSnapshot Industry { get; }
|
||||
|
||||
public LegacyOverseasStockSearchSnapshot Stock { get; }
|
||||
|
||||
public LegacyOverseasMovingAverageSelection MovingAverages { get; }
|
||||
|
||||
public IReadOnlyList<LegacyOverseasActionDefinition> Actions { get; }
|
||||
}
|
||||
|
||||
public sealed record LegacyOverseasPlaylistSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyOverseasPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string SceneAlias,
|
||||
IReadOnlyList<string> Aliases,
|
||||
int PageSize,
|
||||
string InitialPageText,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacyOverseasActionSource Source,
|
||||
LegacyOverseasPlaylistSelection Selection,
|
||||
LegacyOverseasMovingAverageSelection MovingAverages,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
|
||||
public sealed class LegacyOverseasWorkflowException : Exception
|
||||
{
|
||||
public LegacyOverseasWorkflowException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# authority for the UC5 overseas selector. Search services return identities, while Web
|
||||
/// receives generation-bound selection ids and closed action ids only. A slower response can
|
||||
/// never replace the state established by a newer request of the same source.
|
||||
/// </summary>
|
||||
public sealed class LegacyOverseasSelectionWorkflow
|
||||
{
|
||||
public const int DefaultMaximumResults = 100;
|
||||
public const int MaximumResults = 500;
|
||||
public const int MaximumQueryLength = 64;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string InitialPageText = "1/1";
|
||||
private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasIndustrySearchRow> NoIndustryResults =
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasIndustrySearchRow>());
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasStockSearchRow> NoStockResults =
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasStockSearchRow>());
|
||||
|
||||
private readonly IOverseasIndustryIndexSearchService _industrySearchService;
|
||||
private readonly IWorldStockSearchService _stockSearchService;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private LegacyOverseasFixedIndexTarget? _selectedFixedIndex;
|
||||
private LegacyOverseasSearchStatus _industryStatus;
|
||||
private string _industryQuery = string.Empty;
|
||||
private DateTimeOffset? _industryRetrievedAt;
|
||||
private IReadOnlyList<LegacyOverseasIndustrySearchRow> _industryResults = NoIndustryResults;
|
||||
private bool _industryIsTruncated;
|
||||
private LegacyOverseasIndustrySearchRow? _selectedIndustry;
|
||||
private string _industryError = string.Empty;
|
||||
private long _industryGeneration;
|
||||
private LegacyOverseasSearchStatus _stockStatus;
|
||||
private string _stockQuery = string.Empty;
|
||||
private DateTimeOffset? _stockRetrievedAt;
|
||||
private IReadOnlyList<LegacyOverseasStockSearchRow> _stockResults = NoStockResults;
|
||||
private bool _stockIsTruncated;
|
||||
private LegacyOverseasStockSearchRow? _selectedStock;
|
||||
private string _stockError = string.Empty;
|
||||
private long _stockGeneration;
|
||||
private LegacyOverseasMovingAverageSelection _movingAverages;
|
||||
private long _revision;
|
||||
|
||||
public LegacyOverseasSelectionWorkflow(
|
||||
IOverseasIndustryIndexSearchService industrySearchService,
|
||||
IWorldStockSearchService stockSearchService)
|
||||
{
|
||||
_industrySearchService = industrySearchService ??
|
||||
throw new ArgumentNullException(nameof(industrySearchService));
|
||||
_stockSearchService = stockSearchService ??
|
||||
throw new ArgumentNullException(nameof(stockSearchService));
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
|
||||
{
|
||||
var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
|
||||
lock (_sync)
|
||||
{
|
||||
if (!ReferenceEquals(_selectedFixedIndex, target))
|
||||
{
|
||||
_selectedFixedIndex = target;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyOverseasWorkflowSnapshot> SearchIndustriesAsync(
|
||||
string query,
|
||||
int maximumResults = DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
ValidateMaximumResults(maximumResults);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long generation;
|
||||
lock (_sync)
|
||||
{
|
||||
generation = checked(++_industryGeneration);
|
||||
_industryStatus = LegacyOverseasSearchStatus.Loading;
|
||||
_industryQuery = normalizedQuery;
|
||||
_industryRetrievedAt = null;
|
||||
_industryResults = NoIndustryResults;
|
||||
_industryIsTruncated = false;
|
||||
_selectedIndustry = null;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _industrySearchService.SearchAsync(
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
var validated = ValidateIndustryResult(
|
||||
result,
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
generation);
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_industryStatus = LegacyOverseasSearchStatus.Ready;
|
||||
_industryRetrievedAt = result.RetrievedAt;
|
||||
_industryResults = validated;
|
||||
_industryIsTruncated = result.IsTruncated;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation == _industryGeneration)
|
||||
{
|
||||
_industryStatus = LegacyOverseasSearchStatus.Idle;
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _industryGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_industryStatus = LegacyOverseasSearchStatus.Error;
|
||||
_industryRetrievedAt = null;
|
||||
_industryResults = NoIndustryResults;
|
||||
_industryIsTruncated = false;
|
||||
_selectedIndustry = null;
|
||||
_industryError = IndustryError;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyOverseasWorkflowSnapshot> SearchStocksAsync(
|
||||
string query,
|
||||
int maximumResults = DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
ValidateMaximumResults(maximumResults);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long generation;
|
||||
lock (_sync)
|
||||
{
|
||||
generation = checked(++_stockGeneration);
|
||||
_stockStatus = LegacyOverseasSearchStatus.Loading;
|
||||
_stockQuery = normalizedQuery;
|
||||
_stockRetrievedAt = null;
|
||||
_stockResults = NoStockResults;
|
||||
_stockIsTruncated = false;
|
||||
_selectedStock = null;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _stockSearchService.SearchAsync(
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
var validated = ValidateStockResult(
|
||||
result,
|
||||
normalizedQuery,
|
||||
maximumResults,
|
||||
generation);
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_stockStatus = LegacyOverseasSearchStatus.Ready;
|
||||
_stockRetrievedAt = result.RetrievedAt;
|
||||
_stockResults = validated;
|
||||
_stockIsTruncated = result.IsTruncated;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation == _stockGeneration)
|
||||
{
|
||||
_stockStatus = LegacyOverseasSearchStatus.Idle;
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (generation != _stockGeneration)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_stockStatus = LegacyOverseasSearchStatus.Error;
|
||||
_stockRetrievedAt = null;
|
||||
_stockResults = NoStockResults;
|
||||
_stockIsTruncated = false;
|
||||
_selectedStock = null;
|
||||
_stockError = StockError;
|
||||
Touch();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
|
||||
lock (_sync)
|
||||
{
|
||||
var selected = _industryResults.FirstOrDefault(row =>
|
||||
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException("The overseas-industry result is no longer current.");
|
||||
if (!ReferenceEquals(_selectedIndustry, selected))
|
||||
{
|
||||
_selectedIndustry = selected;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
|
||||
lock (_sync)
|
||||
{
|
||||
var selected = _stockResults.FirstOrDefault(row =>
|
||||
string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException("The overseas-stock result is no longer current.");
|
||||
if (!ReferenceEquals(_selectedStock, selected))
|
||||
{
|
||||
_selectedStock = selected;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedFixedIndex is not null)
|
||||
{
|
||||
_selectedFixedIndex = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearIndustrySelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedIndustry is not null)
|
||||
{
|
||||
_selectedIndustry = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot ClearStockSelection()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_selectedStock is not null)
|
||||
{
|
||||
_selectedStock = null;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
|
||||
{
|
||||
var value = new LegacyOverseasMovingAverageSelection(ma5, ma20);
|
||||
lock (_sync)
|
||||
{
|
||||
if (_movingAverages != value)
|
||||
{
|
||||
_movingAverages = value;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds all playlist and scene-selection fields from current native state. The caller can
|
||||
/// select only a closed action id; it cannot supply aliases, data-code identities, paging,
|
||||
/// moving-average text, or fade values.
|
||||
/// </summary>
|
||||
public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionId);
|
||||
var action = LegacyOverseasActionCatalog.FindAction(actionId)
|
||||
?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
return action.Source switch
|
||||
{
|
||||
LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action),
|
||||
LegacyOverseasActionSource.Industry => MaterializeIndustry(action),
|
||||
LegacyOverseasActionSource.Stock => MaterializeStock(action),
|
||||
_ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeFixedIndex(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var target = _selectedFixedIndex ??
|
||||
throw new LegacyOverseasWorkflowException("Select a fixed overseas index first.");
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
return CreateDraft(
|
||||
action,
|
||||
$"{target.DisplayName} 캔들 {action.PeriodDays}일",
|
||||
"chart",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDEX",
|
||||
target.InputName,
|
||||
MovingAverageText(movingAverages),
|
||||
"PRICE",
|
||||
$"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"),
|
||||
movingAverages);
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeIndustry(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var industry = _selectedIndustry ??
|
||||
throw new LegacyOverseasWorkflowException("Select a US overseas industry first.");
|
||||
return CreateDraft(
|
||||
action,
|
||||
industry.KoreanName,
|
||||
"plate",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDUSTRY",
|
||||
industry.KoreanName,
|
||||
"1열판기본",
|
||||
"CURRENT",
|
||||
$"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"),
|
||||
default);
|
||||
}
|
||||
|
||||
private LegacyOverseasPlaylistDraft MaterializeStock(
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var stock = _selectedStock ??
|
||||
throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first.");
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
var current = action.Kind == LegacyOverseasActionKind.Current;
|
||||
return CreateDraft(
|
||||
action,
|
||||
stock.InputName,
|
||||
current ? "stock" : "chart",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_STOCK",
|
||||
stock.InputName,
|
||||
current ? "1열판기본" : MovingAverageText(movingAverages),
|
||||
current ? "CURRENT" : "PRICE",
|
||||
$"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"),
|
||||
movingAverages);
|
||||
}
|
||||
|
||||
private static LegacyOverseasPlaylistDraft CreateDraft(
|
||||
LegacyOverseasActionDefinition action,
|
||||
string title,
|
||||
string category,
|
||||
LegacyOverseasPlaylistSelection selection,
|
||||
LegacyOverseasMovingAverageSelection movingAverages) =>
|
||||
new(
|
||||
action.ActionId,
|
||||
action.BuilderKey,
|
||||
action.SceneAlias,
|
||||
action.Aliases,
|
||||
action.PageSize,
|
||||
InitialPageText,
|
||||
title,
|
||||
action.Label,
|
||||
category,
|
||||
"overseas",
|
||||
action.Source,
|
||||
selection,
|
||||
movingAverages,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
|
||||
private LegacyOverseasMovingAverageSelection MovingAveragesFor(
|
||||
LegacyOverseasActionDefinition action) =>
|
||||
action.SupportsMovingAverages ? _movingAverages : default;
|
||||
|
||||
private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) =>
|
||||
selection switch
|
||||
{
|
||||
{ Ma5: true, Ma20: true } => "MA5,MA20",
|
||||
{ Ma5: true } => "MA5",
|
||||
{ Ma20: true } => "MA20",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private LegacyOverseasWorkflowSnapshot CreateSnapshot() =>
|
||||
new(
|
||||
_revision,
|
||||
LegacyOverseasActionCatalog.FixedIndexTargets,
|
||||
_selectedFixedIndex,
|
||||
new LegacyOverseasIndustrySearchSnapshot(
|
||||
_industryStatus,
|
||||
_industryQuery,
|
||||
_industryRetrievedAt,
|
||||
_industryResults,
|
||||
_industryIsTruncated,
|
||||
_selectedIndustry,
|
||||
_industryError),
|
||||
new LegacyOverseasStockSearchSnapshot(
|
||||
_stockStatus,
|
||||
_stockQuery,
|
||||
_stockRetrievedAt,
|
||||
_stockResults,
|
||||
_stockIsTruncated,
|
||||
_selectedStock,
|
||||
_stockError),
|
||||
_movingAverages,
|
||||
LegacyOverseasActionCatalog.Actions);
|
||||
|
||||
private static IReadOnlyList<LegacyOverseasIndustrySearchRow> ValidateIndustryResult(
|
||||
OverseasIndustryIndexSearchResult? result,
|
||||
string expectedQuery,
|
||||
int maximumResults,
|
||||
long generation)
|
||||
{
|
||||
if (result is null || result.Items is null || result.RetrievedAt == default ||
|
||||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("industry search envelope");
|
||||
}
|
||||
|
||||
var items = result.Items.ToArray();
|
||||
if (items.Length > maximumResults)
|
||||
{
|
||||
throw InvalidData("industry result bound");
|
||||
}
|
||||
|
||||
var koreanNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var inputNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var symbols = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < items.Length; index++)
|
||||
{
|
||||
var item = items[index] ?? throw InvalidData("industry identity");
|
||||
if (!IsSafeName(item.KoreanName) ||
|
||||
!IsSafeName(item.InputName) ||
|
||||
!IsSafeSymbol(item.Symbol) ||
|
||||
!koreanNames.Add(item.KoreanName) ||
|
||||
!inputNames.Add(item.InputName) ||
|
||||
!symbols.Add(item.Symbol) ||
|
||||
index > 0 && CompareIndustry(items[index - 1], item) > 0)
|
||||
{
|
||||
throw InvalidData("industry identity or ordering");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = items.Select((item, index) =>
|
||||
new LegacyOverseasIndustrySearchRow(
|
||||
$"overseas-industry-{generation:D8}-{index + 1:D4}",
|
||||
item));
|
||||
return Array.AsReadOnly(rows.ToArray());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyOverseasStockSearchRow> ValidateStockResult(
|
||||
WorldStockSearchResult? result,
|
||||
string expectedQuery,
|
||||
int maximumResults,
|
||||
long generation)
|
||||
{
|
||||
if (result is null || result.Items is null || result.RetrievedAt == default ||
|
||||
!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("stock search envelope");
|
||||
}
|
||||
|
||||
var items = result.Items.ToArray();
|
||||
if (items.Length > maximumResults)
|
||||
{
|
||||
throw InvalidData("stock result bound");
|
||||
}
|
||||
|
||||
var inputNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var identities = new HashSet<WorldStockSearchItem>();
|
||||
for (var index = 0; index < items.Length; index++)
|
||||
{
|
||||
var item = items[index] ?? throw InvalidData("stock identity");
|
||||
if (!IsSafeName(item.InputName) ||
|
||||
!IsSafeSymbol(item.Symbol) ||
|
||||
item.NationCode is not ("US" or "TW") ||
|
||||
!inputNames.Add(item.InputName) ||
|
||||
!identities.Add(item) ||
|
||||
index > 0 && CompareStock(items[index - 1], item) > 0)
|
||||
{
|
||||
throw InvalidData("stock identity or ordering");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = items.Select((item, index) =>
|
||||
new LegacyOverseasStockSearchRow(
|
||||
$"overseas-stock-{generation:D8}-{index + 1:D4}",
|
||||
item));
|
||||
return Array.AsReadOnly(rows.ToArray());
|
||||
}
|
||||
|
||||
private static int CompareIndustry(
|
||||
OverseasIndustryIndexItem left,
|
||||
OverseasIndustryIndexItem right)
|
||||
{
|
||||
var comparison = CompareUpper(left.KoreanName, right.KoreanName);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right)
|
||||
{
|
||||
var comparison = CompareUpper(left.InputName, right.InputName);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareUpper(string left, string right) =>
|
||||
string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The overseas search query is invalid.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The overseas search query must contain at most {MaximumQueryLength} safe characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void ValidateMaximumResults(int maximumResults)
|
||||
{
|
||||
if (maximumResults is < 1 or > MaximumResults)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maximumResults),
|
||||
$"Overseas results must be limited from 1 through {MaximumResults}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSafeName(string value) =>
|
||||
value.Length is > 0 and <= 200 &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Contains('|', StringComparison.Ordinal) &&
|
||||
IsSafeText(value);
|
||||
|
||||
private static bool IsSafeSymbol(string value) =>
|
||||
value.Length is > 0 and <= 64 &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
IsSafeText(value) &&
|
||||
value.All(character =>
|
||||
char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-');
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LegacyOverseasWorkflowException InvalidData(string detail) =>
|
||||
new($"The overseas selector returned an invalid {detail}.");
|
||||
|
||||
private void Touch() => _revision = checked(_revision + 1);
|
||||
}
|
||||
@@ -18,18 +18,24 @@ public sealed record LegacyOperatorPlaylistRow(
|
||||
string PageText,
|
||||
string StockCode,
|
||||
string RawCutLabel,
|
||||
int FadeDuration)
|
||||
int FadeDuration,
|
||||
bool IsSelected = false,
|
||||
string SceneAlias = "",
|
||||
LegacySceneSelection? PlayoutSelection = null)
|
||||
{
|
||||
public LegacyPlaylistEntry ToPlayoutEntry()
|
||||
{
|
||||
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
|
||||
var cutCode = SceneAlias.Length > 0
|
||||
? SceneAlias
|
||||
: LegacyStockCutCatalog.ResolveCutAlias(
|
||||
RawCutLabel,
|
||||
StockName.Contains("(NXT)", StringComparison.Ordinal));
|
||||
return new LegacyPlaylistEntry(
|
||||
RowId,
|
||||
cutCode,
|
||||
IsEnabled,
|
||||
FadeDuration,
|
||||
new LegacySceneSelection(
|
||||
PlayoutSelection ?? new LegacySceneSelection(
|
||||
MarketText,
|
||||
StockName,
|
||||
GraphicType,
|
||||
|
||||
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
822
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
Normal file
@@ -0,0 +1,822 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyThemeSort
|
||||
{
|
||||
InputOrder,
|
||||
GainRateDescending,
|
||||
GainRateAscending
|
||||
}
|
||||
|
||||
public enum LegacyThemeValueKind
|
||||
{
|
||||
Current,
|
||||
Expected
|
||||
}
|
||||
|
||||
public sealed record LegacyThemeSortDefinition(
|
||||
string Id,
|
||||
string Label,
|
||||
LegacyThemeSort Value);
|
||||
|
||||
public sealed record LegacyThemeActionState(
|
||||
string ActionId,
|
||||
bool IsAvailable,
|
||||
string? UnavailableReason);
|
||||
|
||||
public sealed class LegacyThemeActionDefinition
|
||||
{
|
||||
internal LegacyThemeActionDefinition(
|
||||
string id,
|
||||
string label,
|
||||
string builderKey,
|
||||
string code,
|
||||
int pageSize,
|
||||
LegacyThemeValueKind valueKind)
|
||||
{
|
||||
Id = id;
|
||||
Label = label;
|
||||
BuilderKey = builderKey;
|
||||
Code = code;
|
||||
Aliases = Array.AsReadOnly([code]);
|
||||
PageSize = pageSize;
|
||||
ValueKind = valueKind;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public string BuilderKey { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public IReadOnlyList<string> Aliases { get; }
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
public LegacyThemeValueKind ValueKind { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemeSearchRow
|
||||
{
|
||||
internal LegacyThemeSearchRow(
|
||||
string resultId,
|
||||
ThemeSelectionIdentity identity,
|
||||
DataSourceKind source)
|
||||
{
|
||||
ResultId = resultId;
|
||||
Identity = identity;
|
||||
Market = identity.Market;
|
||||
Session = identity.Session;
|
||||
DisplayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public string ResultId { get; }
|
||||
|
||||
public ThemeMarket Market { get; }
|
||||
|
||||
public ThemeSession Session { get; }
|
||||
|
||||
public string DisplayTitle { get; }
|
||||
|
||||
public DataSourceKind Source { get; }
|
||||
|
||||
internal ThemeSelectionIdentity Identity { get; }
|
||||
}
|
||||
|
||||
public sealed class LegacyThemePreviewRow
|
||||
{
|
||||
internal LegacyThemePreviewRow(ThemeItemPreview item)
|
||||
{
|
||||
InputIndex = item.InputIndex;
|
||||
ItemName = item.ItemName;
|
||||
ItemCode = item.ItemCode;
|
||||
}
|
||||
|
||||
public int InputIndex { get; }
|
||||
|
||||
public string ItemName { get; }
|
||||
|
||||
internal string ItemCode { get; }
|
||||
}
|
||||
|
||||
public sealed record LegacySelectedThemeSummary(
|
||||
ThemeMarket Market,
|
||||
ThemeSession Session,
|
||||
string DisplayTitle,
|
||||
int PreviewItemCount,
|
||||
bool PreviewIsTruncated);
|
||||
|
||||
public sealed class LegacyThemeWorkflowSnapshot
|
||||
{
|
||||
internal LegacyThemeWorkflowSnapshot(
|
||||
object authority,
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated)
|
||||
{
|
||||
Authority = authority;
|
||||
Revision = revision;
|
||||
Query = query;
|
||||
NxtSession = nxtSession;
|
||||
Sort = sort;
|
||||
SearchResults = ReadOnly(searchResults);
|
||||
SearchIsTruncated = searchIsTruncated;
|
||||
SelectedResultId = selectedResultId;
|
||||
SelectedIdentity = selectedIdentity;
|
||||
PreviewItems = ReadOnly(previewItems);
|
||||
PreviewIsTruncated = previewIsTruncated;
|
||||
SelectedTheme = selectedIdentity is null
|
||||
? null
|
||||
: new LegacySelectedThemeSummary(
|
||||
selectedIdentity.Market,
|
||||
selectedIdentity.Session,
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? $"{selectedIdentity.ThemeTitle}(NXT)"
|
||||
: selectedIdentity.ThemeTitle,
|
||||
PreviewItems.Count,
|
||||
previewIsTruncated);
|
||||
ActionStates = LegacyThemeWorkflow.CreateActionStates(
|
||||
selectedIdentity,
|
||||
PreviewItems.Count);
|
||||
}
|
||||
|
||||
public long Revision { get; }
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ThemeSession NxtSession { get; }
|
||||
|
||||
public LegacyThemeSort Sort { get; }
|
||||
|
||||
public string SortId => LegacyThemeWorkflow.SortId(Sort);
|
||||
|
||||
public IReadOnlyList<LegacyThemeSearchRow> SearchResults { get; }
|
||||
|
||||
public bool SearchIsTruncated { get; }
|
||||
|
||||
public string? SelectedResultId { get; }
|
||||
|
||||
public LegacySelectedThemeSummary? SelectedTheme { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemePreviewRow> PreviewItems { get; }
|
||||
|
||||
public bool PreviewIsTruncated { get; }
|
||||
|
||||
public IReadOnlyList<LegacyThemeActionState> ActionStates { get; }
|
||||
|
||||
internal object Authority { get; }
|
||||
|
||||
internal ThemeSelectionIdentity? SelectedIdentity { get; }
|
||||
|
||||
private static IReadOnlyList<T> ReadOnly<T>(IEnumerable<T> source) =>
|
||||
Array.AsReadOnly(source.ToArray());
|
||||
}
|
||||
|
||||
public sealed record LegacyThemePlaylistSelection(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyThemePagePlan(
|
||||
int PreviewItemCount,
|
||||
int AccessibleItemCount,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int MaximumPageCount,
|
||||
bool IsTruncated,
|
||||
string InitialPageText);
|
||||
|
||||
public sealed record LegacyThemePlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string Code,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
LegacyThemePlaylistSelection Selection,
|
||||
LegacyThemeValueKind ValueKind,
|
||||
LegacyThemeSort Sort,
|
||||
int PageSize,
|
||||
LegacyThemePagePlan PagePlan);
|
||||
|
||||
public sealed class LegacyThemeWorkflowDataException : Exception
|
||||
{
|
||||
public LegacyThemeWorkflowDataException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data
|
||||
/// come only from <see cref="IThemeSelectionService"/>; Web callers retain opaque result and
|
||||
/// action ids and never provide scene codes, resolver groups, theme identities, or PageN data.
|
||||
/// </summary>
|
||||
public sealed class LegacyThemeWorkflow
|
||||
{
|
||||
public const int MaximumSearchResults = 100;
|
||||
public const int MaximumPreviewItems = 240;
|
||||
public const int MaximumPageCount = 20;
|
||||
|
||||
private static readonly IReadOnlyList<LegacyThemeActionDefinition> RegisteredActions =
|
||||
CreateActions();
|
||||
private static readonly IReadOnlyList<LegacyThemeSortDefinition> RegisteredSorts =
|
||||
Array.AsReadOnly<LegacyThemeSortDefinition>(
|
||||
[
|
||||
new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder),
|
||||
new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending),
|
||||
new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending)
|
||||
]);
|
||||
private static readonly IReadOnlyDictionary<string, LegacyThemeActionDefinition> ActionsById =
|
||||
new ReadOnlyDictionary<string, LegacyThemeActionDefinition>(
|
||||
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
private readonly IThemeSelectionService _selectionService;
|
||||
private readonly object _authority = new();
|
||||
|
||||
static LegacyThemeWorkflow()
|
||||
{
|
||||
ValidateActions(RegisteredActions);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
|
||||
|
||||
public LegacyThemeWorkflowSnapshot CreateInitial(
|
||||
ThemeSession nxtSession = ThemeSession.PreMarket)
|
||||
{
|
||||
RequireNxtSession(nxtSession);
|
||||
return NewSnapshot(
|
||||
revision: 0,
|
||||
query: string.Empty,
|
||||
nxtSession,
|
||||
LegacyThemeSort.InputOrder,
|
||||
[],
|
||||
searchIsTruncated: false,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var normalizedQuery = NormalizeQuery(query);
|
||||
RequireNxtSession(nxtSession);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await _selectionService.SearchAsync(
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
MaximumSearchResults,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
normalizedQuery,
|
||||
nxtSession,
|
||||
snapshot.Sort,
|
||||
rows,
|
||||
result.IsTruncated,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string resultId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resultId);
|
||||
var selected = snapshot.SearchResults.FirstOrDefault(row =>
|
||||
string.Equals(row.ResultId, resultId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var preview = await _selectionService.PreviewAsync(
|
||||
selected.Identity,
|
||||
MaximumPreviewItems,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var previewRows = ValidatePreviewResult(preview, selected.Identity);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
snapshot.Sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
selected.ResultId,
|
||||
selected.Identity,
|
||||
previewRows,
|
||||
preview.IsTruncated);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot SelectSort(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string sortId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
var sort = ParseSort(sortId);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
sort,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SearchIsTruncated,
|
||||
snapshot.SelectedResultId,
|
||||
snapshot.SelectedIdentity,
|
||||
snapshot.PreviewItems,
|
||||
snapshot.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values,
|
||||
/// theme identity fields, sort values, and PageN calculations are rebuilt in this method.
|
||||
/// </summary>
|
||||
public LegacyThemePlaylistDraft MaterializePlaylistDraft(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
var action = ActionsById.TryGetValue(actionId, out var found)
|
||||
? found
|
||||
: throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
var identity = snapshot.SelectedIdentity
|
||||
?? throw new InvalidOperationException("A theme must be selected and previewed first.");
|
||||
if (snapshot.PreviewItems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("An empty theme preview cannot be added to the playlist.");
|
||||
}
|
||||
|
||||
if (action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
identity.Market == ThemeMarket.Nxt)
|
||||
{
|
||||
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
|
||||
}
|
||||
|
||||
var sortId = SortId(snapshot.Sort);
|
||||
var valueKind = ValueKindId(action.ValueKind);
|
||||
var displayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
var selection = identity.Market == ThemeMarket.Krx
|
||||
? new LegacyThemePlaylistSelection(
|
||||
"PAGED_THEME",
|
||||
identity.ThemeTitle,
|
||||
sortId,
|
||||
valueKind,
|
||||
identity.ThemeCode)
|
||||
: new LegacyThemePlaylistSelection(
|
||||
"PAGED_NXT_THEME",
|
||||
displayTitle,
|
||||
SessionId(identity.Session),
|
||||
sortId,
|
||||
identity.ThemeCode);
|
||||
|
||||
var maximumItems = checked(action.PageSize * MaximumPageCount);
|
||||
var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems);
|
||||
var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize;
|
||||
var pagePlan = new LegacyThemePagePlan(
|
||||
snapshot.PreviewItems.Count,
|
||||
accessibleItems,
|
||||
action.PageSize,
|
||||
pageCount,
|
||||
MaximumPageCount,
|
||||
snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems,
|
||||
$"1/{pageCount.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
return new LegacyThemePlaylistDraft(
|
||||
action.Id,
|
||||
action.BuilderKey,
|
||||
action.Code,
|
||||
action.Aliases,
|
||||
displayTitle,
|
||||
$"{action.Label} · {SortLabel(snapshot.Sort)}",
|
||||
"plate",
|
||||
"theme",
|
||||
selection,
|
||||
action.ValueKind,
|
||||
snapshot.Sort,
|
||||
action.PageSize,
|
||||
pagePlan);
|
||||
}
|
||||
|
||||
public static LegacyThemeActionDefinition? FindAction(string? actionId) =>
|
||||
actionId is not null && ActionsById.TryGetValue(actionId, out var action)
|
||||
? action
|
||||
: null;
|
||||
|
||||
public LegacyThemeActionState GetActionState(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string actionId)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
return snapshot.ActionStates.FirstOrDefault(state =>
|
||||
string.Equals(state.ActionId, actionId, StringComparison.Ordinal))
|
||||
?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'.");
|
||||
}
|
||||
|
||||
public static string SortId(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "INPUT_ORDER",
|
||||
LegacyThemeSort.GainRateDescending => "GAIN_DESC",
|
||||
LegacyThemeSort.GainRateAscending => "GAIN_ASC",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
public static string SortLabel(LegacyThemeSort sort) => sort switch
|
||||
{
|
||||
LegacyThemeSort.InputOrder => "입력순",
|
||||
LegacyThemeSort.GainRateDescending => "상승률순",
|
||||
LegacyThemeSort.GainRateAscending => "하락률순",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.")
|
||||
};
|
||||
|
||||
private LegacyThemeWorkflowSnapshot NewSnapshot(
|
||||
long revision,
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
LegacyThemeSort sort,
|
||||
IEnumerable<LegacyThemeSearchRow> searchResults,
|
||||
bool searchIsTruncated,
|
||||
string? selectedResultId,
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
IEnumerable<LegacyThemePreviewRow> previewItems,
|
||||
bool previewIsTruncated) =>
|
||||
new(
|
||||
_authority,
|
||||
revision,
|
||||
query,
|
||||
nxtSession,
|
||||
sort,
|
||||
searchResults,
|
||||
searchIsTruncated,
|
||||
selectedResultId,
|
||||
selectedIdentity,
|
||||
previewItems,
|
||||
previewIsTruncated);
|
||||
|
||||
private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (!ReferenceEquals(snapshot.Authority, _authority))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The theme snapshot belongs to another workflow instance.",
|
||||
nameof(snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeSearchRow> ValidateSearchResult(
|
||||
ThemeSearchResult result,
|
||||
string query,
|
||||
ThemeSession nxtSession)
|
||||
{
|
||||
if (result is null || result.Items is null ||
|
||||
!string.Equals(result.Query, query, StringComparison.Ordinal) ||
|
||||
result.NxtSession != nxtSession)
|
||||
{
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumSearchResults ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumSearchResults)
|
||||
{
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var titles = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
var codes = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
foreach (var item in serviceItems)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
throw InvalidData("search item");
|
||||
}
|
||||
|
||||
ValidateIdentity(item.Identity, nxtSession);
|
||||
var expectedSource = item.Identity.Market == ThemeMarket.Krx
|
||||
? DataSourceKind.Oracle
|
||||
: DataSourceKind.MariaDb;
|
||||
if (item.Source != expectedSource ||
|
||||
!titles[item.Identity.Market].Add(item.Identity.ThemeTitle) ||
|
||||
!codes[item.Identity.Market].Add(item.Identity.ThemeCode))
|
||||
{
|
||||
throw InvalidData("search identity or provider");
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = serviceItems
|
||||
.OrderBy(item => item.Identity.Market)
|
||||
.ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var rows = new LegacyThemeSearchRow[ordered.Length];
|
||||
for (var index = 0; index < ordered.Length; index++)
|
||||
{
|
||||
rows[index] = new LegacyThemeSearchRow(
|
||||
$"theme-result-{index + 1:000}",
|
||||
ordered[index].Identity,
|
||||
ordered[index].Source);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemePreviewRow> ValidatePreviewResult(
|
||||
ThemePreviewResult result,
|
||||
ThemeSelectionIdentity identity)
|
||||
{
|
||||
if (result is null || result.Items is null || result.Identity != identity)
|
||||
{
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var serviceItems = result.Items.ToArray();
|
||||
if (serviceItems.Length > MaximumPreviewItems ||
|
||||
result.IsTruncated && serviceItems.Length != MaximumPreviewItems)
|
||||
{
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var indexes = new HashSet<int>();
|
||||
var previousIndex = -1;
|
||||
var rows = new LegacyThemePreviewRow[serviceItems.Length];
|
||||
for (var index = 0; index < serviceItems.Length; index++)
|
||||
{
|
||||
var item = serviceItems[index]
|
||||
?? throw InvalidData("preview item");
|
||||
if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex ||
|
||||
!indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) ||
|
||||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
|
||||
!IsCanonicalText(item.ItemName, 200))
|
||||
{
|
||||
throw InvalidData("preview item identity");
|
||||
}
|
||||
|
||||
previousIndex = item.InputIndex;
|
||||
rows[index] = new LegacyThemePreviewRow(item);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
}
|
||||
|
||||
private static void ValidateIdentity(
|
||||
ThemeSelectionIdentity identity,
|
||||
ThemeSession requestedNxtSession)
|
||||
{
|
||||
if (identity is null || !Enum.IsDefined(identity.Market) ||
|
||||
!Enum.IsDefined(identity.Session) ||
|
||||
identity.Market == ThemeMarket.Krx &&
|
||||
identity.Session != ThemeSession.NotApplicable ||
|
||||
identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession ||
|
||||
!IsThemeCode(identity.ThemeCode) ||
|
||||
!IsCanonicalText(identity.ThemeTitle, 128) ||
|
||||
identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData("theme identity");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsSafeText(query))
|
||||
{
|
||||
throw new ArgumentException("The theme search query is invalid.", nameof(query));
|
||||
}
|
||||
|
||||
var normalized = query.Trim();
|
||||
if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength ||
|
||||
!IsSafeText(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.",
|
||||
nameof(query));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static LegacyThemeSort ParseSort(string sortId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sortId);
|
||||
return sortId switch
|
||||
{
|
||||
"INPUT_ORDER" => LegacyThemeSort.InputOrder,
|
||||
"GAIN_DESC" => LegacyThemeSort.GainRateDescending,
|
||||
"GAIN_ASC" => LegacyThemeSort.GainRateAscending,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(sortId),
|
||||
sortId,
|
||||
"Unknown theme sort id.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string SessionId(ThemeSession session) => session switch
|
||||
{
|
||||
ThemeSession.PreMarket => "PRE_MARKET",
|
||||
ThemeSession.AfterMarket => "AFTER_MARKET",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(session),
|
||||
session,
|
||||
"NXT requires an explicit session.")
|
||||
};
|
||||
|
||||
private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch
|
||||
{
|
||||
LegacyThemeValueKind.Current => "CURRENT",
|
||||
LegacyThemeValueKind.Expected => "EXPECTED",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(valueKind),
|
||||
valueKind,
|
||||
"Unknown theme value kind.")
|
||||
};
|
||||
|
||||
private static void RequireNxtSession(ThemeSession nxtSession)
|
||||
{
|
||||
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(nxtSession),
|
||||
nxtSession,
|
||||
"An explicit NXT session is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsThemeCode(string value) =>
|
||||
value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool IsItemCodeForMarket(string value, ThemeMarket market)
|
||||
{
|
||||
if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return market switch
|
||||
{
|
||||
ThemeMarket.Krx => value[0] is 'P' or 'D',
|
||||
ThemeMarket.Nxt => value[0] is 'X' or 'T',
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCanonicalText(string value, int maximumLength) =>
|
||||
value is not null && value.Length is >= 1 &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
IsSafeText(value);
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LegacyThemeActionDefinition> CreateActions() =>
|
||||
Array.AsReadOnly(
|
||||
[
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-current",
|
||||
"5단 표그래프 · 현재가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"five-row-expected",
|
||||
"5단 표그래프 · 예상체결가",
|
||||
"s5074",
|
||||
"5074",
|
||||
5,
|
||||
LegacyThemeValueKind.Expected),
|
||||
new LegacyThemeActionDefinition(
|
||||
"six-row-current",
|
||||
"6종목 현재가",
|
||||
"s5077",
|
||||
"5077",
|
||||
6,
|
||||
LegacyThemeValueKind.Current),
|
||||
new LegacyThemeActionDefinition(
|
||||
"twelve-row-current",
|
||||
"12종목 현재가",
|
||||
"s5088",
|
||||
"5088",
|
||||
12,
|
||||
LegacyThemeValueKind.Current)
|
||||
]);
|
||||
|
||||
internal static IReadOnlyList<LegacyThemeActionState> CreateActionStates(
|
||||
ThemeSelectionIdentity? selectedIdentity,
|
||||
int previewItemCount)
|
||||
{
|
||||
var states = new LegacyThemeActionState[RegisteredActions.Count];
|
||||
for (var index = 0; index < RegisteredActions.Count; index++)
|
||||
{
|
||||
var action = RegisteredActions[index];
|
||||
var reason = selectedIdentity is null
|
||||
? "theme-not-selected"
|
||||
: previewItemCount == 0
|
||||
? "theme-preview-is-empty"
|
||||
: action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? "expected-theme-is-krx-only"
|
||||
: null;
|
||||
states[index] = new LegacyThemeActionState(
|
||||
action.Id,
|
||||
reason is null,
|
||||
reason);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(states);
|
||||
}
|
||||
|
||||
private static void ValidateActions(
|
||||
IReadOnlyCollection<LegacyThemeActionDefinition> actions)
|
||||
{
|
||||
if (actions.Count != 4 ||
|
||||
actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4)
|
||||
{
|
||||
throw new InvalidOperationException("The UC4 action catalog must contain four unique actions.");
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var scene = LegacySceneCatalog.Definitions.Single(definition =>
|
||||
string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal));
|
||||
if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) ||
|
||||
action.Aliases.Count != 1 ||
|
||||
!string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) ||
|
||||
(int)scene.PageSize != action.PageSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Theme action '{action.Id}' does not match the Core scene catalog.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyThemeWorkflowDataException InvalidData(string detail) =>
|
||||
new($"The injected theme selection service returned invalid {detail} data.");
|
||||
}
|
||||
@@ -14,4 +14,9 @@
|
||||
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Catalog\LegacyFixedActions.json"
|
||||
LogicalName="MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyManualListScreen
|
||||
{
|
||||
NetSell,
|
||||
Vi
|
||||
}
|
||||
|
||||
public enum LegacyManualNetSellField
|
||||
{
|
||||
LeftName,
|
||||
LeftAmount,
|
||||
RightName,
|
||||
RightAmount
|
||||
}
|
||||
|
||||
public enum LegacyManualListMoveDirection
|
||||
{
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
|
||||
public sealed record LegacyManualListStoreAvailability(
|
||||
bool IsAvailable,
|
||||
string Code,
|
||||
string? Message);
|
||||
|
||||
public sealed record LegacyManualViStoredItem(string Code, string Name);
|
||||
|
||||
public sealed record LegacyManualViStoreSnapshot(
|
||||
IReadOnlyList<LegacyManualViStoredItem> Items,
|
||||
string RowVersion);
|
||||
|
||||
public sealed record LegacyManualViWriteReceipt(
|
||||
LegacyManualViStoreSnapshot Snapshot,
|
||||
bool Persisted);
|
||||
|
||||
public sealed record LegacyManualImportReceipt(
|
||||
int MarkerVersion,
|
||||
string SourceSha256,
|
||||
DateTimeOffset ImportedAt,
|
||||
int NetSellRowCount,
|
||||
int ViItemCount);
|
||||
|
||||
public sealed class LegacyManualImportStoreException : Exception
|
||||
{
|
||||
public LegacyManualImportStoreException(string code, bool outcomeUnknown)
|
||||
: base("The legacy manual-data import did not complete.")
|
||||
{
|
||||
Code = code;
|
||||
OutcomeUnknown = outcomeUnknown;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public bool OutcomeUnknown { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Native-only boundary for the operator-owned CP949 files. Implementations choose
|
||||
/// every path and file name; the WebView can only select a closed screen or opaque row.
|
||||
/// </summary>
|
||||
public interface ILegacyManualListStore
|
||||
{
|
||||
LegacyManualListStoreAvailability Availability { get; }
|
||||
|
||||
Task<IReadOnlyList<S5025TrustedManualRow>> ReadNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task WriteNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualViStoreSnapshot> ReadViAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualViWriteReceipt> WriteViAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> items,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LegacyManualImportReceipt> ImportAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LegacyManualNetSellRowView(
|
||||
string RowId,
|
||||
string LeftName,
|
||||
string LeftAmount,
|
||||
string RightName,
|
||||
string RightAmount);
|
||||
|
||||
public sealed record LegacyManualViItemView(
|
||||
string ItemId,
|
||||
int Index,
|
||||
string Name);
|
||||
|
||||
public sealed record LegacyManualViSearchResultView(
|
||||
string ResultId,
|
||||
string DisplayName,
|
||||
string MarketText);
|
||||
|
||||
public sealed record LegacyManualListsSnapshot(
|
||||
bool IsOpen,
|
||||
LegacyManualListScreen? Screen,
|
||||
S5025ManualAudience Audience,
|
||||
bool IsAvailable,
|
||||
string AvailabilityCode,
|
||||
string? AvailabilityMessage,
|
||||
bool WritesQuarantined,
|
||||
IReadOnlyList<LegacyManualNetSellRowView> NetRows,
|
||||
bool NetRowsAreFresh,
|
||||
IReadOnlyList<LegacyManualViItemView> ViItems,
|
||||
int ViPageCount,
|
||||
bool ViItemsAreFresh,
|
||||
string SearchQuery,
|
||||
IReadOnlyList<LegacyManualViSearchResultView> SearchResults,
|
||||
string StatusMessage,
|
||||
LegacyOperatorStatusKind StatusKind,
|
||||
bool CanSave,
|
||||
bool CanMaterializePlaylist)
|
||||
{
|
||||
public string? SelectedSearchResultId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LegacyManualListPlaylistDraft(
|
||||
string SceneAlias,
|
||||
string MarketText,
|
||||
string StockName,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText,
|
||||
string RawCutLabel,
|
||||
int FadeDuration,
|
||||
LegacySceneSelection Selection);
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative FSell/VIList workflow. Draft rows, stock identities, file versions,
|
||||
/// save verification and playlist materialization remain in C#; browser messages are
|
||||
/// limited to input intent and opaque identifiers.
|
||||
/// </summary>
|
||||
public sealed class LegacyManualListsWorkflow
|
||||
{
|
||||
public const int NetSellRowCount = 5;
|
||||
public const int MaximumViItems = 100;
|
||||
public const int ViPageSize = 5;
|
||||
public const int MaximumPageCount = 20;
|
||||
public const int MaximumNetSellValueLength = 256;
|
||||
public const int MaximumSearchLength = 100;
|
||||
|
||||
private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1";
|
||||
private const string OutcomeUnknownMessage =
|
||||
"저장 결과를 확인할 수 없어 수동 목록 쓰기를 격리했습니다. 앱을 다시 시작하고 파일을 확인하세요.";
|
||||
|
||||
private readonly ILegacyManualListStore _store;
|
||||
private readonly ILegacyStockLookup _stockLookup;
|
||||
private readonly List<S5025TrustedManualRow> _netRows = CreateEmptyNetRows();
|
||||
private readonly List<ViDraftItem> _viItems = [];
|
||||
private readonly List<ViSearchResult> _searchResults = [];
|
||||
private LegacyManualListScreen? _screen;
|
||||
private S5025ManualAudience _audience = S5025ManualAudience.Individual;
|
||||
private bool _isOpen;
|
||||
private bool _netRowsAreFresh;
|
||||
private bool _viItemsAreFresh;
|
||||
private bool _writesQuarantined;
|
||||
private string? _viVersion;
|
||||
private string _searchQuery = string.Empty;
|
||||
private string? _selectedSearchResultId;
|
||||
private string _statusMessage = string.Empty;
|
||||
private LegacyOperatorStatusKind _statusKind;
|
||||
private long _opaqueSequence;
|
||||
|
||||
public LegacyManualListsWorkflow(
|
||||
ILegacyManualListStore store,
|
||||
ILegacyStockLookup stockLookup)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot Current => CreateSnapshot();
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> OpenNetSellAsync(
|
||||
S5025ManualAudience audience,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enum.IsDefined(audience))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(audience));
|
||||
}
|
||||
|
||||
_isOpen = true;
|
||||
_screen = LegacyManualListScreen.NetSell;
|
||||
_audience = audience;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> OpenViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_isOpen = true;
|
||||
_screen = LegacyManualListScreen.Vi;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
await ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot Close()
|
||||
{
|
||||
_isOpen = false;
|
||||
_screen = null;
|
||||
_searchResults.Clear();
|
||||
_searchQuery = string.Empty;
|
||||
_selectedSearchResultId = null;
|
||||
SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> RefreshAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_screen == LegacyManualListScreen.NetSell)
|
||||
{
|
||||
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (_screen == LegacyManualListScreen.Vi)
|
||||
{
|
||||
await ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot SetNetSellCell(
|
||||
string rowId,
|
||||
LegacyManualNetSellField field,
|
||||
string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rowId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (_screen != LegacyManualListScreen.NetSell ||
|
||||
!TryParseNetRowId(rowId, out var index) ||
|
||||
!IsSafeNetValue(value) ||
|
||||
!Enum.IsDefined(field))
|
||||
{
|
||||
SetStatus("수동 순매도 입력값을 확인하세요.", LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
var row = _netRows[index];
|
||||
_netRows[index] = field switch
|
||||
{
|
||||
LegacyManualNetSellField.LeftName => row with { LeftName = value },
|
||||
LegacyManualNetSellField.LeftAmount => row with { LeftAmount = value },
|
||||
LegacyManualNetSellField.RightName => row with { RightName = value },
|
||||
LegacyManualNetSellField.RightAmount => row with { RightAmount = value },
|
||||
_ => row
|
||||
};
|
||||
_netRowsAreFresh = false;
|
||||
SetStatus("변경 내용을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SaveNetSellAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!CanWrite(LegacyManualListScreen.NetSell))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
var expected = _netRows.ToArray();
|
||||
try
|
||||
{
|
||||
await _store.WriteNetSellAsync(_audience, expected, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await RecoverNetSellWriteAsync(expected, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await VerifyNetSellWriteAsync(expected, recovered: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SearchViAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
_searchQuery = query.Trim();
|
||||
_searchResults.Clear();
|
||||
_selectedSearchResultId = null;
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
_searchQuery.Length == 0 ||
|
||||
_searchQuery.Length > MaximumSearchLength)
|
||||
{
|
||||
SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var displayName in data.DisplayNames.Take(200))
|
||||
{
|
||||
var identity = data.ResolveLastByDisplayName(displayName);
|
||||
if (identity is not null)
|
||||
{
|
||||
_searchResults.Add(new ViSearchResult(
|
||||
NextOpaqueId("manual-vi-result"),
|
||||
identity));
|
||||
}
|
||||
}
|
||||
|
||||
SetStatus(
|
||||
_searchResults.Count == 0
|
||||
? "검색 결과가 없습니다."
|
||||
: $"{_searchResults.Count}개 종목을 찾았습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("종목 검색을 완료하지 못했습니다.", LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot SelectViSearchResult(string resultId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resultId);
|
||||
if (_screen == LegacyManualListScreen.Vi && _searchResults.Any(item =>
|
||||
string.Equals(item.ResultId, resultId, StringComparison.Ordinal)))
|
||||
{
|
||||
_selectedSearchResultId = resultId;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(
|
||||
"The selected stock is no longer in the current VI search generation.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot AddViSearchResult(string resultId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resultId);
|
||||
var result = _searchResults.SingleOrDefault(item =>
|
||||
string.Equals(item.ResultId, resultId, StringComparison.Ordinal));
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
result is null ||
|
||||
_viItems.Count >= MaximumViItems)
|
||||
{
|
||||
SetStatus("선택한 종목을 VI 목록에 추가할 수 없습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
_selectedSearchResultId = resultId;
|
||||
|
||||
var prefix = result.Identity.Market is
|
||||
LegacyDomesticStockMarket.Kospi or LegacyDomesticStockMarket.NxtKospi
|
||||
? "P"
|
||||
: "D";
|
||||
var item = new LegacyManualViStoredItem(
|
||||
prefix + result.Identity.StockCode,
|
||||
result.Identity.DisplayName);
|
||||
if (!IsValidViItem(item))
|
||||
{
|
||||
SetStatus("선택한 종목 정보가 VI 파일 형식에 맞지 않습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
_viItems.Add(new ViDraftItem(NextOpaqueId("manual-vi-item"), item));
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 목록을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot MoveViItem(
|
||||
string itemId,
|
||||
LegacyManualListMoveDirection direction)
|
||||
{
|
||||
var index = FindViItem(itemId);
|
||||
var target = direction == LegacyManualListMoveDirection.Up ? index - 1 : index + 1;
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
index < 0 || target < 0 || target >= _viItems.Count ||
|
||||
!Enum.IsDefined(direction))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
(_viItems[index], _viItems[target]) = (_viItems[target], _viItems[index]);
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 순서를 변경했습니다. 저장 후 재조회하세요.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot DeleteViItem(string itemId)
|
||||
{
|
||||
var index = FindViItem(itemId);
|
||||
if (_screen == LegacyManualListScreen.Vi && index >= 0)
|
||||
{
|
||||
_viItems.RemoveAt(index);
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 종목을 삭제했습니다. 저장 후 재조회하세요.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListsSnapshot DeleteAllViItems()
|
||||
{
|
||||
if (_screen == LegacyManualListScreen.Vi && _viItems.Count > 0)
|
||||
{
|
||||
_viItems.Clear();
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus("VI 목록을 비웠습니다. 원본과 같이 빈 목록 저장은 기존 파일을 유지합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> SaveViAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!CanWrite(LegacyManualListScreen.Vi) ||
|
||||
!TrustedViManualReference.IsRowVersion(_viVersion))
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
var expectedVersion = _viVersion;
|
||||
var expectedItems = _viItems.Select(item => item.Stored).ToArray();
|
||||
LegacyManualViStoreSnapshot preflight;
|
||||
try
|
||||
{
|
||||
preflight = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("VI 저장 전 파일 버전을 확인하지 못했습니다. 저장하지 않았습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
return Current;
|
||||
}
|
||||
|
||||
if (!string.Equals(preflight.RowVersion, expectedVersion, StringComparison.Ordinal))
|
||||
{
|
||||
SetStatus("VI 파일이 다른 작업에서 변경되었습니다. 재조회 후 다시 편집하세요.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
|
||||
LegacyManualViWriteReceipt receipt;
|
||||
try
|
||||
{
|
||||
receipt = await _store.WriteViAsync(expectedItems, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await RecoverViWriteAsync(expectedItems, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await VerifyViWriteAsync(
|
||||
expectedItems,
|
||||
receipt,
|
||||
recovered: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LegacyManualListsSnapshot> ImportAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.Availability.IsAvailable || _writesQuarantined)
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var receipt = await _store.ImportAsync(cancellationToken).ConfigureAwait(false);
|
||||
SetStatus(
|
||||
$"원본 수동 데이터를 가져왔습니다. 순매도 {receipt.NetSellRowCount}행, VI {receipt.ViItemCount}종목.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return await RefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LegacyManualImportStoreException exception)
|
||||
{
|
||||
if (exception.OutcomeUnknown)
|
||||
{
|
||||
QuarantineWrites();
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(ImportFailureMessage(exception.Code), LegacyOperatorStatusKind.Warning);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetStatus("원본 수동 데이터 가져오기를 완료하지 못했습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
return Current;
|
||||
}
|
||||
|
||||
public LegacyManualListPlaylistDraft CreateNetSellPlaylistDraft()
|
||||
{
|
||||
if (_screen != LegacyManualListScreen.NetSell || !_netRowsAreFresh)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The net-sell rows must be saved and re-read before materialization.");
|
||||
}
|
||||
|
||||
var (groupCode, label) = _audience switch
|
||||
{
|
||||
S5025ManualAudience.Individual => ("INDIVIDUAL", "개인"),
|
||||
S5025ManualAudience.Foreign => ("FOREIGN", "외국인"),
|
||||
S5025ManualAudience.Institution => ("INSTITUTION", "기관"),
|
||||
_ => throw new InvalidOperationException("The manual audience is invalid.")
|
||||
};
|
||||
return new LegacyManualListPlaylistDraft(
|
||||
"5025",
|
||||
"지수",
|
||||
$"{label} 순매도 상위(수동)",
|
||||
"순매도 상위",
|
||||
"순매도 상위",
|
||||
"1/1",
|
||||
$"{label} 순매도 상위(수동)",
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
groupCode,
|
||||
"INDEX",
|
||||
"MANUAL_NET_SELL",
|
||||
"MANUAL_NET_SELL",
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
public LegacyManualListPlaylistDraft CreateViPlaylistDraft()
|
||||
{
|
||||
if (_screen != LegacyManualListScreen.Vi ||
|
||||
!_viItemsAreFresh ||
|
||||
_viItems.Count == 0 ||
|
||||
!TrustedViManualReference.IsRowVersion(_viVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The VI rows must be saved and re-read before materialization.");
|
||||
}
|
||||
|
||||
var names = string.Join(",", _viItems.Select(item => item.Stored.Name));
|
||||
var pageCount = (_viItems.Count + ViPageSize - 1) / ViPageSize;
|
||||
var rowVersion = _viVersion ?? throw new InvalidOperationException(
|
||||
"The trusted VI row version is unavailable.");
|
||||
return new LegacyManualListPlaylistDraft(
|
||||
"5074",
|
||||
"지수",
|
||||
names,
|
||||
"5단 표그래프",
|
||||
"VI 발동(수동)",
|
||||
$"1/{pageCount}",
|
||||
"5단 표그래프_VI 발동(수동)",
|
||||
6,
|
||||
new LegacySceneSelection(
|
||||
"PAGED_VI",
|
||||
ViSnapshotSubject,
|
||||
"INPUT_ORDER",
|
||||
"CURRENT",
|
||||
rowVersion));
|
||||
}
|
||||
|
||||
private async Task ReadNetSellAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_netRowsAreFresh = false;
|
||||
if (!EnsureAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (rows.Count != NetSellRowCount || rows.Any(row => !IsValidNetRow(row)))
|
||||
{
|
||||
throw new InvalidDataException("The FSell file shape is invalid.");
|
||||
}
|
||||
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(rows);
|
||||
_netRowsAreFresh = true;
|
||||
SetStatus("수동 순매도 5행을 조회했습니다.", LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(CreateEmptyNetRows());
|
||||
SetStatus("수동 순매도 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadViAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_viItemsAreFresh = false;
|
||||
_viVersion = null;
|
||||
if (!EnsureAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
SetViSnapshot(snapshot);
|
||||
SetStatus($"VI 수동 목록 {snapshot.Items.Count}종목을 조회했습니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_viItems.Clear();
|
||||
SetStatus("VI 수동 목록 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> RecoverNetSellWriteAsync(
|
||||
IReadOnlyList<S5025TrustedManualRow> expected,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (actual.SequenceEqual(expected))
|
||||
{
|
||||
SetNetRows(actual);
|
||||
SetStatus("저장 응답은 실패했지만 재조회로 동일한 5행을 확인했습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> VerifyNetSellWriteAsync(
|
||||
IReadOnlyList<S5025TrustedManualRow> expected,
|
||||
bool recovered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadNetSellAsync(_audience, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (actual.SequenceEqual(expected))
|
||||
{
|
||||
SetNetRows(actual);
|
||||
SetStatus(
|
||||
recovered
|
||||
? "재조회로 수동 순매도 저장을 확인했습니다."
|
||||
: "저장 후 재조회한 5행이 입력값과 일치합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> RecoverViWriteAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> expected,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (expected.Count > 0 && actual.Items.SequenceEqual(expected))
|
||||
{
|
||||
SetViSnapshot(actual);
|
||||
SetStatus("저장 응답은 실패했지만 재조회로 같은 VI 목록을 확인했습니다.",
|
||||
LegacyOperatorStatusKind.Warning);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private async Task<LegacyManualListsSnapshot> VerifyViWriteAsync(
|
||||
IReadOnlyList<LegacyManualViStoredItem> expected,
|
||||
LegacyManualViWriteReceipt receipt,
|
||||
bool recovered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false);
|
||||
var receiptMatches = actual.Items.SequenceEqual(receipt.Snapshot.Items) &&
|
||||
string.Equals(actual.RowVersion, receipt.Snapshot.RowVersion,
|
||||
StringComparison.Ordinal);
|
||||
var expectedMatches = !receipt.Persisted || actual.Items.SequenceEqual(expected);
|
||||
if (receiptMatches && expectedMatches &&
|
||||
TrustedViManualReference.IsRowVersion(actual.RowVersion))
|
||||
{
|
||||
SetViSnapshot(actual);
|
||||
SetStatus(
|
||||
receipt.Persisted
|
||||
? recovered
|
||||
? "재조회로 VI 저장을 확인했습니다."
|
||||
: "VI 저장 후 재조회한 목록과 버전이 일치합니다."
|
||||
: "빈 목록은 원본 동작과 같이 파일을 덮어쓰지 않아 기존 목록을 다시 표시합니다.",
|
||||
LegacyOperatorStatusKind.Information);
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
QuarantineWrites();
|
||||
return Current;
|
||||
}
|
||||
|
||||
private bool CanWrite(LegacyManualListScreen requiredScreen)
|
||||
{
|
||||
if (_screen != requiredScreen || !_isOpen || !EnsureAvailable() || _writesQuarantined)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EnsureAvailable()
|
||||
{
|
||||
if (_store.Availability.IsAvailable)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
SetStatus(
|
||||
_store.Availability.Message ?? "수동 목록 저장소를 사용할 수 없습니다.",
|
||||
LegacyOperatorStatusKind.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetNetRows(IEnumerable<S5025TrustedManualRow> rows)
|
||||
{
|
||||
_netRows.Clear();
|
||||
_netRows.AddRange(rows);
|
||||
_netRowsAreFresh = true;
|
||||
}
|
||||
|
||||
private void SetViSnapshot(LegacyManualViStoreSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.Items.Count > MaximumViItems ||
|
||||
snapshot.Items.Any(item => !IsValidViItem(item)) ||
|
||||
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion))
|
||||
{
|
||||
throw new InvalidDataException("The VI snapshot is invalid.");
|
||||
}
|
||||
|
||||
_viItems.Clear();
|
||||
_viItems.AddRange(snapshot.Items.Select(item =>
|
||||
new ViDraftItem(NextOpaqueId("manual-vi-item"), item)));
|
||||
_viVersion = snapshot.RowVersion;
|
||||
_viItemsAreFresh = true;
|
||||
}
|
||||
|
||||
private LegacyManualListsSnapshot CreateSnapshot()
|
||||
{
|
||||
var availability = _store.Availability;
|
||||
var netRows = _netRows.Select((row, index) => new LegacyManualNetSellRowView(
|
||||
NetRowId(index),
|
||||
row.LeftName,
|
||||
row.LeftAmount,
|
||||
row.RightName,
|
||||
row.RightAmount)).ToArray();
|
||||
var viItems = _viItems.Select((item, index) => new LegacyManualViItemView(
|
||||
item.ItemId,
|
||||
index + 1,
|
||||
item.Stored.Name)).ToArray();
|
||||
var search = _searchResults.Select(item => new LegacyManualViSearchResultView(
|
||||
item.ResultId,
|
||||
item.Identity.DisplayName,
|
||||
item.Identity.MarketText)).ToArray();
|
||||
var canSave = _isOpen && availability.IsAvailable && !_writesQuarantined &&
|
||||
(_screen == LegacyManualListScreen.NetSell ||
|
||||
(_screen == LegacyManualListScreen.Vi &&
|
||||
TrustedViManualReference.IsRowVersion(_viVersion)));
|
||||
var canMaterialize = _screen switch
|
||||
{
|
||||
LegacyManualListScreen.NetSell => _netRowsAreFresh,
|
||||
LegacyManualListScreen.Vi => _viItemsAreFresh && _viItems.Count > 0 &&
|
||||
TrustedViManualReference.IsRowVersion(_viVersion),
|
||||
_ => false
|
||||
};
|
||||
return new LegacyManualListsSnapshot(
|
||||
_isOpen,
|
||||
_screen,
|
||||
_audience,
|
||||
availability.IsAvailable,
|
||||
availability.Code,
|
||||
availability.Message,
|
||||
_writesQuarantined,
|
||||
Array.AsReadOnly(netRows),
|
||||
_netRowsAreFresh,
|
||||
Array.AsReadOnly(viItems),
|
||||
(_viItems.Count + ViPageSize - 1) / ViPageSize,
|
||||
_viItemsAreFresh,
|
||||
_searchQuery,
|
||||
Array.AsReadOnly(search),
|
||||
_statusMessage,
|
||||
_statusKind,
|
||||
canSave,
|
||||
canMaterialize)
|
||||
{
|
||||
SelectedSearchResultId = _selectedSearchResultId
|
||||
};
|
||||
}
|
||||
|
||||
private static List<S5025TrustedManualRow> CreateEmptyNetRows() =>
|
||||
Enumerable.Range(0, NetSellRowCount)
|
||||
.Select(_ => new S5025TrustedManualRow(string.Empty, string.Empty, string.Empty, string.Empty))
|
||||
.ToList();
|
||||
|
||||
private static bool IsValidNetRow(S5025TrustedManualRow row) =>
|
||||
row is not null &&
|
||||
IsSafeNetValue(row.LeftName) &&
|
||||
IsSafeNetValue(row.LeftAmount) &&
|
||||
IsSafeNetValue(row.RightName) &&
|
||||
IsSafeNetValue(row.RightAmount);
|
||||
|
||||
private static bool IsSafeNetValue(string? value) =>
|
||||
value is not null &&
|
||||
value.Length <= MaximumNetSellValueLength &&
|
||||
!value.Contains('^') &&
|
||||
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
|
||||
|
||||
private static bool IsValidViItem(LegacyManualViStoredItem item) =>
|
||||
item is not null &&
|
||||
IsSafeViText(item.Code, 64) &&
|
||||
IsSafeViText(item.Name, 200) &&
|
||||
!item.Name.Contains(',');
|
||||
|
||||
private static bool IsSafeViText(string? value, int maximumLength) =>
|
||||
value is { Length: > 0 } &&
|
||||
value.Length <= maximumLength &&
|
||||
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
||||
!value.Contains('^') &&
|
||||
!value.Any(character => char.IsControl(character) || character == '\uFFFD');
|
||||
|
||||
private static string NetRowId(int index) => $"manual-net-row-{index + 1}";
|
||||
|
||||
private static bool TryParseNetRowId(string rowId, out int index)
|
||||
{
|
||||
index = Array.FindIndex(
|
||||
Enumerable.Range(0, NetSellRowCount).Select(NetRowId).ToArray(),
|
||||
value => string.Equals(value, rowId, StringComparison.Ordinal));
|
||||
return index >= 0;
|
||||
}
|
||||
|
||||
private int FindViItem(string itemId) => _viItems.FindIndex(item =>
|
||||
string.Equals(item.ItemId, itemId, StringComparison.Ordinal));
|
||||
|
||||
private string NextOpaqueId(string prefix) => $"{prefix}-{++_opaqueSequence:D8}";
|
||||
|
||||
private void SetStatus(string message, LegacyOperatorStatusKind kind)
|
||||
{
|
||||
_statusMessage = message;
|
||||
_statusKind = kind;
|
||||
}
|
||||
|
||||
private void QuarantineWrites()
|
||||
{
|
||||
_writesQuarantined = true;
|
||||
_netRowsAreFresh = false;
|
||||
_viItemsAreFresh = false;
|
||||
SetStatus(OutcomeUnknownMessage, LegacyOperatorStatusKind.Error);
|
||||
}
|
||||
|
||||
private static string ImportFailureMessage(string code) => code switch
|
||||
{
|
||||
"SOURCE_UNAVAILABLE" => "원본 수동 데이터 파일을 찾을 수 없습니다.",
|
||||
"SOURCE_INVALID" => "원본 수동 데이터 파일 형식이 올바르지 않습니다.",
|
||||
"ALREADY_IMPORTED" => "원본 수동 데이터는 이미 한 번 가져왔습니다.",
|
||||
"DESTINATION_NOT_EMPTY" => "현재 저장소에 파일이 있어 원본 데이터를 덮어쓰지 않았습니다.",
|
||||
"INTERRUPTED_IMPORT" => "이전 가져오기 작업이 중단된 상태입니다. 파일을 먼저 확인하세요.",
|
||||
_ => "원본 수동 데이터 가져오기를 완료하지 못했습니다."
|
||||
};
|
||||
|
||||
private sealed record ViDraftItem(string ItemId, LegacyManualViStoredItem Stored);
|
||||
|
||||
private sealed record ViSearchResult(string ResultId, LegacyStockIdentity Identity);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.LegacyApplication.Tests")]
|
||||
@@ -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