Files
MBN_STOCK_WEBVIEW/tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs

803 lines
31 KiB
C#

using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;
internal static class NamedPlaylistReadOnlyDbAudit
{
private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
private const int MaximumRows = 20_000;
private const string Sql = """
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
FROM (
SELECT TRIM(d.DC_CODE) DC_CODE,
d.DC_TITLE DC_TITLE,
p.LIST_TEXT LIST_TEXT,
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
FROM DC_LIST d
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
)
WHERE ROWNUM <= :row_limit
""";
internal static async Task RunAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
Console.WriteLine(
$"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
var definitions = await persistence
.ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (definitions.IsTruncated || definitions.Items.Count == 0)
{
throw InvalidBoundary("definition cardinality");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");
var rawByProgram = rawRows
.GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
StringComparer.Ordinal);
if (rawByProgram.Count != definitions.Items.Count ||
rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
{
throw InvalidBoundary("active definition join");
}
var loadedRows = 0;
foreach (var definition in definitions.Items)
{
cancellationToken.ThrowIfCancellationRequested();
if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
!LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
programRows.Any(row =>
!string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
{
throw InvalidBoundary("program identity or item order");
}
var loaded = await persistence
.LoadAsync(definition.ProgramCode, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
{
throw InvalidBoundary("typed load cardinality");
}
for (var index = 0; index < programRows.Count; index++)
{
if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
programRows[index],
loaded.Items[index]))
{
throw InvalidBoundary("lossless typed load comparison");
}
}
loadedRows += loaded.Items.Count;
}
if (loadedRows != rawRows.Count)
{
throw InvalidBoundary("typed load total");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");
IReadOnlySet<SelectionKey> fixedSignatures;
try
{
fixedSignatures = LoadFixedSignatures();
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
throw;
}
Console.WriteLine(
"PLIST_FIXED_MAPPER_AUDIT: " +
$"sourceActions=328 unavailable=6 " +
$"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
"sourceHashes=verified");
var routedRows = rawRows
.Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
.ToArray();
var routeCounts = routedRows
.GroupBy(static routed => routed.Route)
.ToDictionary(static group => group.Key, static group => group.Count());
MaterializationSummary outcomes;
try
{
outcomes = await ResolveMaterializationAsync(
executor,
routedRows,
routeCounts,
cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
throw;
}
if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
{
throw InvalidBoundary("materialization total");
}
Console.WriteLine(
"PLIST_READ_AUDIT: " +
$"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
$"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
"replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
Console.WriteLine(
"PLIST_ROUTE_AUDIT: " + string.Join(
",",
Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
$"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
Console.WriteLine(
"PLIST_MATERIALIZATION_AUDIT: " +
$"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
"sceneDataAndAssetsNotAsserted=true");
Console.WriteLine(
"PLIST_BLOCK_REASONS: " +
(outcomes.BlockReasons.Count == 0
? "none"
: string.Join(
",",
outcomes.BlockReasons
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair =>
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
}
private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
Sql,
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
if (table is null || table.Columns.Count != columns.Length ||
table.Rows.Count is < 1 or > MaximumRows)
{
throw InvalidBoundary("query schema or row bound");
}
for (var index = 0; index < columns.Length; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidBoundary("query schema");
}
}
var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
foreach (DataRow dataRow in table.Rows)
{
var result = LegacyPListReadAuditParser.Parse(
dataRow[0] as string,
dataRow[1] as string,
dataRow[3] as string,
dataRow[2] as string);
if (!result.IsSuccess)
{
// The failure enum contains no source text. Never echo raw
// operator data or identifiers from this audit boundary.
throw InvalidBoundary($"row parse ({result.Failure})");
}
rows.Add(result.Row!);
}
return rows.AsReadOnly();
}
private static async Task<MaterializationSummary> ResolveMaterializationAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> routedRows,
IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
CancellationToken cancellationToken)
{
var summary = new MaterializationSummary();
// These closed classifiers create one immutable selection candidate.
// This audit does not call the scene data loaders or assert asset and
// PREPARE readiness. Industry fixed rows are independently constrained
// to two markets and three actions by LegacyPListReadAuditEvidence.
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.ExistingClosedMapper));
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.IndustryFixedClosedMapper));
var comparisonRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ComparisonFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToComparisonRow(row) with { ItemIndex = index });
if (comparisonRows.Count > 0)
{
var resolved = await new LegacyNamedComparisonRestoreService(executor)
.ResolveAsync(comparisonRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var foreignIndexRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToForeignIndexCandleRow(row) with { ItemIndex = index });
if (foreignIndexRows.Count > 0)
{
var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
.ResolveAsync(foreignIndexRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var nxtRows = RowsFor(
routedRows,
LegacyPListAuditRoute.NxtThemeFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToNxtThemeRow(row) with { ItemIndex = index });
if (nxtRows.Count > 0)
{
var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync(nxtRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
// GraphE/FSell/VI require a fresh row-version or opened-handle trusted
// file read. The DB-only audit intentionally does not claim those
// external proofs and keeps every such row blocked with one reason.
summary.AddBlocked(
"MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
summary.AddBlocked(
"NO_UNIQUE_CLOSED_MAPPER",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));
return summary;
}
private static async Task ResolveKrxThemesAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.KrxThemeFreshIdentity,
static (row, index) => new LegacyNamedKrxThemeRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
var remapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
if (row.Identity!.CodeWasRemapped)
{
remapped++;
}
}
else
{
summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
}
}
Console.WriteLine(
"PLIST_KRX_THEME_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
}
private static async Task ResolveForeignStocksAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.ForeignStockFreshIdentity,
static (row, index) => new LegacyNamedForeignStockRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
}
else
{
summary.AddBlocked(
"FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
}
}
var actions = resolved.Rows
.Where(static row => row.IsResolved)
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var databaseIssues = resolved.Rows
.Where(static row =>
row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
.GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var slashSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains('/'));
var nonAsciiSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
var hangulSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
var internalSpaceSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains(' '));
var hangulActions = resolved.Rows
.Where(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"PLIST_FOREIGN_STOCK_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
$"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"actions={string.Join(',', actions)} " +
$"hangulActions={string.Join(',', hangulActions)} " +
$"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
"rawValuesPrinted=false");
}
private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
failure switch
{
LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static string ForeignStockFailureToken(
LegacyNamedForeignStockRestoreFailure failure) =>
failure switch
{
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static IReadOnlyList<T> RowsFor<T>(
IReadOnlyList<RoutedRow> rows,
LegacyPListAuditRoute route,
Func<LegacyPListAuditRow, int, T> convert) =>
rows
.Where(row => row.Route == route)
.Select(static row => row.Row)
.Select(convert)
.ToArray();
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsHangulCodeUnit(char value) =>
value is >= '\uAC00' and <= '\uD7AF' or
>= '\u1100' and <= '\u11FF' or
>= '\u3130' and <= '\u318F';
private static InvalidDataException InvalidBoundary(string detail) =>
new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");
private static LegacyPListAuditRoute ClassifyRoute(
LegacyPListAuditRow row,
IReadOnlySet<SelectionKey> fixedSignatures)
{
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
if (route != LegacyPListAuditRoute.Unmapped)
{
return route;
}
var key = new SelectionKey(
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.DataCode);
return fixedSignatures.Contains(key)
? LegacyPListAuditRoute.ExistingClosedMapper
: LegacyPListAuditRoute.Unmapped;
}
private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile))
{
throw InvalidBoundary("legacy fixed-profile root");
}
var directory = Path.GetFullPath(Path.Combine(
profile,
"source",
"repos",
"MBN_STOCK_N",
"MBN_STOCK_N",
"bin",
"Debug",
"Res"));
if (!Directory.Exists(directory) ||
(File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source directory");
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
var signatureCounts = new Dictionary<SelectionKey, int>();
var actionCount = 0;
foreach (var source in FixedSources)
{
var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
if (!string.Equals(
Path.GetDirectoryName(path),
directory,
StringComparison.OrdinalIgnoreCase) ||
!File.Exists(path) ||
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source file");
}
byte[] bytes;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
{
if (stream.Length is < 1 or > 32_768)
{
throw InvalidBoundary("legacy fixed source size");
}
bytes = new byte[stream.Length];
stream.ReadExactly(bytes);
}
if (!string.Equals(
Convert.ToHexString(SHA256.HashData(bytes)),
source.ExpectedSha256,
StringComparison.Ordinal))
{
throw InvalidBoundary("legacy fixed source hash");
}
string text;
try
{
text = encoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidBoundary("legacy fixed source encoding");
}
AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
}
// The three audited runtime INI files contain 328 source actions. Six
// unavailable manual placeholders are deliberately excluded below.
if (actionCount != 322)
{
throw InvalidBoundary("legacy fixed signature cardinality");
}
// A raw row must identify exactly one current action. If two source
// actions collapse to one historical five-field signature, retain no
// signature for that key and let the row fail closed as unmapped.
return signatureCounts
.Where(static pair => pair.Value == 1)
.Select(static pair => pair.Key)
.ToHashSet();
}
private static void AppendFixedSignatures(
IDictionary<SelectionKey, int> signatureCounts,
string market,
string text,
ref int actionCount)
{
string? section = null;
foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
{
// MainForm's runtime INI loader calls Trim() on every line before
// it builds the tree and playlist signature.
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
{
section = line[1..^1];
if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
{
throw InvalidBoundary("legacy fixed section");
}
continue;
}
if (section is null || line.Contains('^') ||
LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
{
throw InvalidBoundary("legacy fixed action");
}
if (line == "VI 발동(수동)" ||
section == "매매동향" && line is
("개인 순매도 상위(수동)" or
"외국인 순매도 상위(수동)" or
"기관 순매도 상위(수동)"))
{
continue;
}
actionCount = checked(actionCount + 1);
var separator = line.IndexOf('_');
var first = separator < 0 ? line : line[..separator];
var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
SelectionKey signature;
if (market is "overseas" or "exchange")
{
signature = new SelectionKey(
market == "overseas" ? "해외지수" : "환율",
first,
section,
remainder,
string.Empty);
}
else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
section,
line,
string.Empty);
}
else if (section == "매매동향")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
first,
remainder,
string.Empty);
}
else
{
signature = new SelectionKey(
section,
"지수",
first,
remainder,
string.Empty);
}
signatureCounts.TryGetValue(signature, out var existingCount);
signatureCounts[signature] = checked(existingCount + 1);
}
}
private static string FixedIndexGroup(string label)
{
if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
return "전체";
}
private sealed class MaterializationSummary
{
public int Unique { get; private set; }
public int Playable { get; private set; }
public int Blocked { get; private set; }
public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);
public void AddPlayable(int count)
{
if (count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Playable = checked(Playable + count);
}
public void AddBlocked(string reason, int count = 1)
{
if (string.IsNullOrWhiteSpace(reason) || count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Blocked = checked(Blocked + count);
BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
}
}
private readonly record struct RoutedRow(
LegacyPListAuditRow Row,
LegacyPListAuditRoute Route);
private readonly record struct SelectionKey(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
private sealed record FixedSource(
string FileName,
string Market,
string ExpectedSha256);
private static readonly IReadOnlyList<FixedSource> FixedSources =
[
new(
"해외.ini",
"overseas",
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
new(
"환율.ini",
"exchange",
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
new(
"지수.ini",
"index",
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
];
}