feat: complete legacy UI and playout parity migration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user