feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -0,0 +1,529 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text;
namespace MMoneyCoderSharp.Data;
public enum LegacyNamedForeignStockRestoreFailure
{
InvalidRow,
IdentityNotFound,
IdentityAmbiguous,
DatabaseDataInvalid
}
/// <summary>
/// Non-sensitive reason token for read-only diagnostics. No database value is
/// carried, so aggregate audits can distinguish schema, correlation and symbol
/// contract failures without printing an input name or symbol.
/// </summary>
public enum LegacyNamedForeignStockDatabaseIssue
{
SchemaOrRowBound,
ValueType,
InputNameMismatch,
InputNameInvalid,
SymbolEmpty,
SymbolTooLong,
SymbolNotCanonical,
SymbolDataDelimiter,
SymbolCaret,
SymbolUnsupportedSlash,
SymbolUnsupportedPlus,
SymbolUnsupportedAmpersand,
SymbolUnsupportedApostrophe,
SymbolUnsupportedComma,
SymbolUnsupportedParenthesis,
SymbolUnsupportedHash,
SymbolUnsupportedPercent,
SymbolUnsupportedAsterisk,
SymbolUnsupportedEquals,
SymbolUnsupportedBackslash,
SymbolUnsupportedAsciiPunctuation,
SymbolNonAsciiFullWidth,
SymbolNonAsciiHangul,
SymbolNonAsciiCjk,
SymbolNonAsciiWhitespace,
SymbolNonAsciiLetterOrNumber,
SymbolNonAsciiPunctuation,
SymbolNonAsciiOther,
NationInvalid,
ForeignDomesticInvalid
}
/// <summary>
/// One untrusted seven-field PLAY_LIST row from the original UC5 overseas-stock
/// selector. The row has an input name but did not persist its symbol or nation.
/// </summary>
public sealed record LegacyNamedForeignStockRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
public sealed record LegacyNamedForeignStockRestoreIdentity(
string ActionId,
string InputName,
string Symbol,
string NationCode,
string ForeignDomesticCode,
string BuilderKey,
string CutCode,
string ValueType,
int? PeriodDays);
public sealed record LegacyNamedForeignStockRestoreOutcome(
int ItemIndex,
LegacyNamedForeignStockRestoreIdentity? Identity,
LegacyNamedForeignStockRestoreFailure? Failure,
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null)
{
public bool IsResolved =>
Identity is not null && Failure is null && DatabaseIssue is null;
}
public sealed record LegacyNamedForeignStockRestoreResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyNamedForeignStockRestoreOutcome> Rows);
/// <summary>
/// Resolves the original UC5 `해외종목` rows against today's Oracle master.
/// Only an exact F_INPUT_NAME that identifies one distinct US/TW symbol is
/// accepted. Queries are bound and read-only, and this service never retries.
/// </summary>
public sealed class LegacyNamedForeignStockRestoreService
{
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
public const string QueryName = "LEGACY_NAMED_FOREIGN_STOCK_RESTORE";
private const string ExpectedGroupCode = "해외종목";
private const string ExpectedPageText = "1/1";
private const string ForeignDomesticCode = "1";
private const string InputNameColumn = "F_INPUT_NAME";
private const string SymbolColumn = "F_SYMB";
private const string NationCodeColumn = "F_NATC";
private const string ForeignDomesticColumn = "F_FDTC";
private const int MaximumInputNameLength = 200;
private const int MaximumSymbolLength = 64;
private const string IdentitySql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
FROM (
SELECT DISTINCT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
FROM T_WORLD_IX_EQ_MASTER
WHERE F_FDTC = '1'
AND F_NATC IN ('US', 'TW')
AND F_INPUT_NAME = :input_name
ORDER BY F_SYMB, F_NATC
)
WHERE ROWNUM <= 2
""";
private static readonly IReadOnlyDictionary<string, ActionProfile> CandleActions =
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
{
["5일"] = new("stock-candle-5d", "s8010", "8061", "PRICE", 5),
["20일"] = new("stock-candle-20d", "s8010", "8040", "PRICE", 20),
["60일"] = new("stock-candle-60d", "s8010", "8046", "PRICE", 60),
["120일"] = new("stock-candle-120d", "s8010", "8051", "PRICE", 120),
["240일"] = new("stock-candle-240d", "s8010", "8056", "PRICE", 240)
};
private static readonly ActionProfile CurrentAction =
new("stock-current", "s5001", "5001", "CURRENT", PeriodDays: null);
private readonly IDataQueryExecutor _executor;
private readonly TimeProvider _timeProvider;
public LegacyNamedForeignStockRestoreService(IDataQueryExecutor executor)
: this(executor, TimeProvider.System)
{
}
internal LegacyNamedForeignStockRestoreService(
IDataQueryExecutor executor,
TimeProvider timeProvider)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Routes only the exact legacy group and closed UC5 action grammar. The
/// canonical FOREIGN_STOCK format is deliberately handled by its existing
/// identity-bearing workflow and cannot collide with this restore pass.
/// </summary>
public static bool IsCandidate(LegacyNamedForeignStockRestoreRow? row) =>
row is not null &&
row.ItemIndex is >= 0 and < MaximumRows &&
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) &&
IsCanonicalText(row.Subject, MaximumInputNameLength, allowEmpty: false) &&
TryGetAction(row.GraphicType, row.Subtype, out _) &&
string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) &&
string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal);
public async Task<LegacyNamedForeignStockRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedForeignStockRestoreRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count is < 1 or > MaximumRows)
{
throw new ArgumentOutOfRangeException(
nameof(rows),
$"A named foreign-stock restore must contain 1 through {MaximumRows} rows.");
}
var itemIndexes = new HashSet<int>();
foreach (var row in rows)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!itemIndexes.Add(row.ItemIndex))
{
throw new ArgumentException(
"Named foreign-stock restore rows must have unique bounded item indexes.",
nameof(rows));
}
}
cancellationToken.ThrowIfCancellationRequested();
var identityCache = new Dictionary<string, IdentityResolution>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedForeignStockRestoreOutcome>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
outcomes.Add(await ResolveRowAsync(row, identityCache, cancellationToken)
.ConfigureAwait(false));
}
return new LegacyNamedForeignStockRestoreResult(
_timeProvider.GetLocalNow(),
outcomes.AsReadOnly());
}
private async Task<LegacyNamedForeignStockRestoreOutcome> ResolveRowAsync(
LegacyNamedForeignStockRestoreRow row,
IDictionary<string, IdentityResolution> identityCache,
CancellationToken cancellationToken)
{
if (!IsCandidate(row) ||
!TryGetAction(row.GraphicType, row.Subtype, out var action))
{
return Failed(row.ItemIndex, LegacyNamedForeignStockRestoreFailure.InvalidRow);
}
if (!identityCache.TryGetValue(row.Subject, out var resolution))
{
resolution = await ResolveIdentityAsync(row.Subject, cancellationToken)
.ConfigureAwait(false);
identityCache.Add(row.Subject, resolution);
}
if (resolution.Failure.HasValue)
{
return Failed(
row.ItemIndex,
resolution.Failure.Value,
resolution.DatabaseIssue);
}
var identity = resolution.Identity!;
return new LegacyNamedForeignStockRestoreOutcome(
row.ItemIndex,
new LegacyNamedForeignStockRestoreIdentity(
action.ActionId,
identity.InputName,
identity.Symbol,
identity.NationCode,
ForeignDomesticCode,
action.BuilderKey,
action.CutCode,
action.ValueType,
action.PeriodDays),
Failure: null,
DatabaseIssue: null);
}
private async Task<IdentityResolution> ResolveIdentityAsync(
string inputName,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
IdentitySql,
[new DataQueryParameter("input_name", inputName, DbType.String)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return ValidateIdentityTable(table, inputName);
}
private static IdentityResolution ValidateIdentityTable(DataTable? table, string inputName)
{
if (table is null || table.Columns.Count != 4 || table.Rows.Count > 2 ||
!HasExactStringColumn(table.Columns[0], InputNameColumn) ||
!HasExactStringColumn(table.Columns[1], SymbolColumn) ||
!HasExactStringColumn(table.Columns[2], NationCodeColumn) ||
!HasExactStringColumn(table.Columns[3], ForeignDomesticColumn))
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound);
}
var identities = new List<WorldStockSearchItem>(table.Rows.Count);
foreach (DataRow row in table.Rows)
{
if (row[0] is not string foundInputName ||
row[1] is not string symbol ||
row[2] is not string nationCode ||
row[3] is not string foreignDomestic)
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ValueType);
}
if (!string.Equals(foundInputName, inputName, StringComparison.Ordinal))
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameMismatch);
}
if (!IsCanonicalText(foundInputName, MaximumInputNameLength, allowEmpty: false))
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameInvalid);
}
var symbolIssue = GetSymbolIssue(symbol);
if (symbolIssue.HasValue)
{
return InvalidDatabaseData(symbolIssue.Value);
}
if (nationCode is not ("US" or "TW"))
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.NationInvalid);
}
if (!string.Equals(foreignDomestic, ForeignDomesticCode, StringComparison.Ordinal))
{
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ForeignDomesticInvalid);
}
identities.Add(new WorldStockSearchItem(foundInputName, symbol, nationCode));
}
return identities.Count switch
{
0 => new IdentityResolution(
Identity: null,
LegacyNamedForeignStockRestoreFailure.IdentityNotFound),
1 => new IdentityResolution(identities[0], Failure: null),
2 => new IdentityResolution(
Identity: null,
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous),
_ => InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound)
};
}
private static bool TryGetAction(
string? graphicType,
string? subtype,
out ActionProfile action)
{
action = null!;
if (string.Equals(graphicType, "1열판기본", StringComparison.Ordinal) &&
string.Equals(subtype, string.Empty, StringComparison.Ordinal))
{
action = CurrentAction;
return true;
}
return string.Equals(graphicType, "캔들그래프", StringComparison.Ordinal) &&
subtype is not null &&
CandleActions.TryGetValue(subtype, out action!);
}
internal static bool IsCanonicalBoundSymbol(string? value) =>
value is not null && GetSymbolIssue(value) is null;
private static LegacyNamedForeignStockDatabaseIssue? GetSymbolIssue(string value)
{
if (value.Length == 0)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolEmpty;
}
if (value.Length > MaximumSymbolLength)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolTooLong;
}
if (!string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
}
foreach (var rune in value.EnumerateRunes())
{
if (rune.Value == '|')
{
return LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter;
}
if (rune.Value == '^')
{
return LegacyNamedForeignStockDatabaseIssue.SymbolCaret;
}
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 LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
}
// Oracle currently contains a small set of provider symbols with
// Hangul letters and internal ASCII spaces. They are safe identity
// data: the runtime passes the symbol only as a bound parameter,
// canonical validation rejects edge whitespace, and serialized
// identity uses '|' (not a letter/number/space) as its delimiter.
if (Rune.IsLetterOrDigit(rune))
{
continue;
}
if (rune.Value > 0x7F)
{
if (rune.Value is >= 0xFF01 and <= 0xFF5E)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiFullWidth;
}
if (rune.Value is >= 0xAC00 and <= 0xD7AF or
>= 0x1100 and <= 0x11FF or
>= 0x3130 and <= 0x318F)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiHangul;
}
if (rune.Value is >= 0x3400 and <= 0x9FFF or
>= 0xF900 and <= 0xFAFF)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiCjk;
}
if (Rune.IsWhiteSpace(rune))
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiWhitespace;
}
if (category is UnicodeCategory.ConnectorPunctuation or
UnicodeCategory.DashPunctuation or
UnicodeCategory.OpenPunctuation or
UnicodeCategory.ClosePunctuation or
UnicodeCategory.InitialQuotePunctuation or
UnicodeCategory.FinalQuotePunctuation or
UnicodeCategory.OtherPunctuation)
{
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiPunctuation;
}
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther;
}
var character = (char)rune.Value;
if (character == '/')
{
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedSlash;
}
var punctuationIssue = character switch
{
'+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus,
'&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand,
'\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe,
',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma,
'(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis,
'#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash,
'%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent,
'*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk,
'=' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedEquals,
'\\' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedBackslash,
_ => (LegacyNamedForeignStockDatabaseIssue?)null
};
if (punctuationIssue.HasValue)
{
return punctuationIssue.Value;
}
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' '))
{
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation;
}
}
return null;
}
private static bool IsCanonicalText(string? value, int maximumLength, bool allowEmpty)
{
if (value is null || value.Length > maximumLength ||
(!allowEmpty && value.Length == 0) ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
return false;
}
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 !value.Contains('^');
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static LegacyNamedForeignStockRestoreOutcome Failed(
int itemIndex,
LegacyNamedForeignStockRestoreFailure failure,
LegacyNamedForeignStockDatabaseIssue? databaseIssue = null) =>
new(itemIndex, Identity: null, failure, databaseIssue);
private static IdentityResolution InvalidDatabaseData(
LegacyNamedForeignStockDatabaseIssue issue) =>
new(
Identity: null,
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid,
issue);
private sealed record ActionProfile(
string ActionId,
string BuilderKey,
string CutCode,
string ValueType,
int? PeriodDays);
private sealed record IdentityResolution(
WorldStockSearchItem? Identity,
LegacyNamedForeignStockRestoreFailure? Failure,
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null);
}

View File

@@ -0,0 +1,487 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public enum LegacyNamedKrxThemeRestoreFailure
{
InvalidRow,
UnsupportedAction,
IdentityNotFound,
IdentityAmbiguous,
PreviewEmpty,
DatabaseDataInvalid
}
/// <summary>
/// One untrusted seven-field PLAY_LIST row routed to the KRX-theme verifier.
/// Every original field remains evidence; a successful restore uses the
/// current Oracle identity for scene selection.
/// </summary>
public sealed record LegacyNamedKrxThemeRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
/// <summary>
/// A closed current KRX identity. StoredThemeCode is evidence only and must
/// never be used by PREPARE after this identity has been resolved.
/// </summary>
public sealed record LegacyNamedKrxThemeRestoreIdentity(
string ActionId,
string BuilderKey,
string CutCode,
int PageSize,
string Sort,
string ThemeTitle,
string StoredThemeCode,
string CurrentThemeCode,
int PreviewItemCount,
bool PreviewIsTruncated)
{
public bool CodeWasRemapped =>
!string.Equals(StoredThemeCode, CurrentThemeCode, StringComparison.Ordinal);
}
public sealed record LegacyNamedKrxThemeRestoreOutcome(
int ItemIndex,
LegacyNamedKrxThemeRestoreIdentity? Identity,
LegacyNamedKrxThemeRestoreFailure? Failure,
string? ObservedCurrentThemeCode = null)
{
public bool IsResolved => Identity is not null && Failure is null;
}
public sealed record LegacyNamedKrxThemeRestoreResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyNamedKrxThemeRestoreOutcome> Rows);
/// <summary>
/// Rehydrates legacy `테마` rows through exact, bound Oracle reads. The old
/// s5074/s5077/s5088 constructors looked the code up by exact title immediately
/// before loading data. This service closes that lookup to one title and one
/// code, validates the selected items through LegacyThemeSelectionService, and
/// retains the stale stored code solely for lossless PLAY_LIST persistence.
/// </summary>
public sealed partial class LegacyNamedKrxThemeRestoreService
{
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
public const string IdentityQueryName = "LEGACY_NAMED_KRX_THEME_IDENTITY";
private const string ExpectedGroupCode = "테마";
private const int MaximumTitleLength = 128;
private static readonly IReadOnlyDictionary<string, SubtypeProfile> Subtypes =
new Dictionary<string, SubtypeProfile>(StringComparer.Ordinal)
{
["테마-현재가(입력순)"] = new("CURRENT", "INPUT_ORDER"),
["테마-현재가(상승률순)"] = new("CURRENT", "GAIN_DESC"),
["테마-현재가(하락률순)"] = new("CURRENT", "GAIN_ASC"),
// All three original scene constructors fall through to their
// final loss-rate branch when no parenthesized token is present.
["테마-현재가"] = new("CURRENT", "GAIN_ASC"),
["테마-예상체결가(입력순)"] = new("EXPECTED", "INPUT_ORDER"),
["테마-예상체결가(상승률순)"] = new("EXPECTED", "GAIN_DESC"),
["테마-예상체결가(하락률순)"] = new("EXPECTED", "GAIN_ASC"),
["테마-예상체결가"] = new("EXPECTED", "GAIN_ASC")
};
private static readonly IReadOnlyDictionary<string, ActionProfile> Actions =
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
{
[ActionKey("5단 표그래프", "CURRENT")] =
new("five-row-current", "s5074", "5074", 5),
[ActionKey("5단 표그래프", "EXPECTED")] =
new("five-row-expected", "s5074", "5074", 5),
[ActionKey("6종목 현재가", "CURRENT")] =
new("six-row-current", "s5077", "5077", 6),
[ActionKey("12종목 현재가", "CURRENT")] =
new("twelve-row-current", "s5088", "5088", 12)
};
private const string IdentitySql = """
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, CODE_IDENTITY_COUNT
FROM (
SELECT s.SB_TITLE THEME_TITLE,
s.SB_CODE THEME_CODE,
s.SB_MARKET THEME_MARKET,
TO_CHAR((SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = 'KRX'
AND d.SB_CODE = s.SB_CODE), 'FM999999990')
CODE_IDENTITY_COUNT
FROM SB_LIST s
WHERE s.SB_MARKET = 'KRX'
AND s.SB_TITLE = :theme_title
ORDER BY s.SB_CODE
)
WHERE ROWNUM <= 2
""";
private static readonly string[] IdentityColumns =
["THEME_TITLE", "THEME_CODE", "THEME_MARKET", "CODE_IDENTITY_COUNT"];
private readonly IDataQueryExecutor _executor;
private readonly LegacyThemeSelectionService _themeSelectionService;
private readonly TimeProvider _timeProvider;
public LegacyNamedKrxThemeRestoreService(IDataQueryExecutor executor)
: this(executor, TimeProvider.System)
{
}
internal LegacyNamedKrxThemeRestoreService(
IDataQueryExecutor executor,
TimeProvider timeProvider)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_themeSelectionService = new LegacyThemeSelectionService(_executor);
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Routes every exact `테마` group row, including malformed grammar, so
/// native verification returns an explicit closed result rather than
/// allowing the stale code through the generic mapper.
/// </summary>
public static bool IsCandidate(LegacyNamedKrxThemeRestoreRow? row) =>
row is not null &&
row.ItemIndex is >= 0 and < MaximumRows &&
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal);
public async Task<LegacyNamedKrxThemeRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedKrxThemeRestoreRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count is < 1 or > MaximumRows)
{
throw new ArgumentOutOfRangeException(
nameof(rows),
$"A KRX-theme restore must contain 1 through {MaximumRows} rows.");
}
var indexes = new HashSet<int>();
foreach (var row in rows)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!indexes.Add(row.ItemIndex))
{
throw new ArgumentException(
"KRX-theme restore rows must have unique bounded item indexes.",
nameof(rows));
}
}
var verifications = new Dictionary<string, ThemeVerification>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedKrxThemeRestoreOutcome>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
outcomes.Add(await ResolveRowAsync(row, verifications, cancellationToken)
.ConfigureAwait(false));
}
return new LegacyNamedKrxThemeRestoreResult(
_timeProvider.GetLocalNow(),
outcomes.AsReadOnly());
}
private async Task<LegacyNamedKrxThemeRestoreOutcome> ResolveRowAsync(
LegacyNamedKrxThemeRestoreRow row,
IDictionary<string, ThemeVerification> verifications,
CancellationToken cancellationToken)
{
var classification = Classify(row);
if (classification.Failure.HasValue)
{
return Failed(row.ItemIndex, classification.Failure.Value);
}
var title = classification.Title!;
if (!verifications.TryGetValue(title, out var verification))
{
verification = await VerifyThemeAsync(title, cancellationToken).ConfigureAwait(false);
verifications.Add(title, verification);
}
if (verification.Failure.HasValue)
{
return Failed(
row.ItemIndex,
verification.Failure.Value,
verification.CurrentThemeCode);
}
var profile = classification.Action!;
return new LegacyNamedKrxThemeRestoreOutcome(
row.ItemIndex,
new LegacyNamedKrxThemeRestoreIdentity(
profile.ActionId,
profile.BuilderKey,
profile.CutCode,
profile.PageSize,
classification.Sort!,
title,
row.DataCode,
verification.CurrentThemeCode!,
verification.PreviewItemCount,
verification.PreviewIsTruncated),
Failure: null,
ObservedCurrentThemeCode: verification.CurrentThemeCode);
}
private static RowClassification Classify(LegacyNamedKrxThemeRestoreRow row)
{
if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) ||
!IsSafeCanonicalText(row.Subject, MaximumTitleLength) ||
row.Subject.Contains("(NXT)", StringComparison.Ordinal) ||
!StoredThemeCodePattern().IsMatch(row.DataCode ?? string.Empty) ||
!IsValidStoredPage(row.PageText))
{
return new RowClassification(Failure: LegacyNamedKrxThemeRestoreFailure.InvalidRow);
}
if (!Subtypes.TryGetValue(row.Subtype ?? string.Empty, out var subtype))
{
return new RowClassification(
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
}
if (!Actions.TryGetValue(
ActionKey(row.GraphicType ?? string.Empty, subtype.ValueKind),
out var action))
{
return new RowClassification(
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
}
return new RowClassification(row.Subject, action, subtype.Sort, Failure: null);
}
private async Task<ThemeVerification> VerifyThemeAsync(
string title,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
IdentitySql,
[new DataQueryParameter("theme_title", title, DbType.String)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
IdentityQueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var identity = ValidateIdentityTable(table, title);
if (identity.Failure.HasValue)
{
return identity;
}
var selectedIdentity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
identity.CurrentThemeCode!,
title);
ThemePreviewResult preview;
try
{
preview = await _themeSelectionService.PreviewAsync(
selectedIdentity,
LegacyThemeSelectionService.MaximumPreviewItems,
cancellationToken)
.ConfigureAwait(false);
}
catch (ThemeSelectionDataException)
{
return InvalidDatabaseData(identity.CurrentThemeCode);
}
if (!Equals(preview.Identity, selectedIdentity) ||
preview.Items.Count > LegacyThemeSelectionService.MaximumPreviewItems ||
preview.IsTruncated &&
preview.Items.Count != LegacyThemeSelectionService.MaximumPreviewItems)
{
return InvalidDatabaseData(identity.CurrentThemeCode);
}
if (preview.Items.Count == 0)
{
return identity with
{
Failure = LegacyNamedKrxThemeRestoreFailure.PreviewEmpty
};
}
return identity with
{
PreviewItemCount = preview.Items.Count,
PreviewIsTruncated = preview.IsTruncated
};
}
private static ThemeVerification ValidateIdentityTable(DataTable? table, string title)
{
if (table is null || table.Columns.Count != IdentityColumns.Length ||
table.Rows.Count > 2)
{
return InvalidDatabaseData();
}
for (var index = 0; index < IdentityColumns.Length; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
IdentityColumns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
return InvalidDatabaseData();
}
}
if (table.Rows.Count == 0)
{
return new ThemeVerification(
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityNotFound);
}
if (table.Rows.Count > 1)
{
return new ThemeVerification(
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
}
var row = table.Rows[0];
if (row[0] is not string actualTitle ||
row[1] is not string code ||
row[2] is not string market ||
!string.Equals(actualTitle, title, StringComparison.Ordinal) ||
!string.Equals(market, "KRX", StringComparison.Ordinal) ||
!StoredThemeCodePattern().IsMatch(code) ||
!TryReadCount(row[3], out var codeIdentityCount))
{
return InvalidDatabaseData();
}
if (codeIdentityCount != 1)
{
return new ThemeVerification(
CurrentThemeCode: code,
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
}
return new ThemeVerification(CurrentThemeCode: code, Failure: null);
}
private static bool TryReadCount(object value, out int result)
{
result = 0;
return value is string text &&
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out result) &&
result is >= 0 and <= 1_000_000 &&
string.Equals(
text,
result.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal);
}
private static bool IsValidStoredPage(string value)
{
var match = StoredPagePattern().Match(value ?? string.Empty);
if (!match.Success ||
!int.TryParse(
match.Groups[1].Value,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var current) ||
!int.TryParse(
match.Groups[2].Value,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var total))
{
return false;
}
return current is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
total is >= 0 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
(total == 0 ? current == 1 : current <= total);
}
private static bool IsSafeCanonicalText(string? value, int maximumLength)
{
if (string.IsNullOrEmpty(value) || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains('^'))
{
return false;
}
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 string ActionKey(string graphicType, string valueKind) =>
string.Concat(graphicType, "\u001F", valueKind);
private static LegacyNamedKrxThemeRestoreOutcome Failed(
int itemIndex,
LegacyNamedKrxThemeRestoreFailure failure,
string? observedCurrentThemeCode = null) =>
new(itemIndex, Identity: null, failure, observedCurrentThemeCode);
private static ThemeVerification InvalidDatabaseData(string? currentThemeCode = null) =>
new(
CurrentThemeCode: currentThemeCode,
Failure: LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid);
private sealed record ActionProfile(
string ActionId,
string BuilderKey,
string CutCode,
int PageSize);
private sealed record SubtypeProfile(string ValueKind, string Sort);
private sealed record RowClassification(
string? Title = null,
ActionProfile? Action = null,
string? Sort = null,
LegacyNamedKrxThemeRestoreFailure? Failure = null);
private sealed record ThemeVerification(
string? CurrentThemeCode = null,
int PreviewItemCount = 0,
bool PreviewIsTruncated = false,
LegacyNamedKrxThemeRestoreFailure? Failure = null);
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
private static partial Regex StoredThemeCodePattern();
[GeneratedRegex("^([1-9][0-9]{0,3})/([0-9]{1,4})$", RegexOptions.CultureInvariant)]
private static partial Regex StoredPagePattern();
}

View File

@@ -0,0 +1,511 @@
#nullable enable
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// Closed kinds understood by the named-playlist manual restore workflow. The
/// candidate deliberately has a non-data-bearing ToString implementation so an
/// audit failure cannot accidentally print an operator stock name or VI list.
/// </summary>
public enum LegacyNamedManualRestoreKind
{
Financial,
NetSell,
Vi
}
public enum LegacyNamedManualFreshProofFailure
{
None,
FinancialSnapshotChanged,
FinancialStockSearchTruncated,
FinancialStockIdentityNotUnique,
FinancialSelectionMismatch,
NetSellSnapshotInvalid,
ViSnapshotInvalid,
ViSnapshotVersionChanged,
ViHistoricalNamesChanged,
ViPageCountChanged
}
public sealed class LegacyNamedManualRestoreCandidate
{
internal LegacyNamedManualRestoreCandidate(
int itemIndex,
LegacyNamedManualRestoreKind kind,
bool isHistorical,
ManualFinancialScreenKind? financialScreen = null,
StockMarket? market = null,
string? stockName = null,
S5025ManualAudience? audience = null,
IReadOnlyList<string>? historicalViNames = null,
string? expectedViVersion = null,
int expectedPageCount = 1)
{
ItemIndex = itemIndex;
Kind = kind;
IsHistorical = isHistorical;
FinancialScreen = financialScreen;
Market = market;
StockName = stockName;
Audience = audience;
HistoricalViNames = historicalViNames;
ExpectedViVersion = expectedViVersion;
ExpectedPageCount = expectedPageCount;
}
public int ItemIndex { get; }
public LegacyNamedManualRestoreKind Kind { get; }
public bool IsHistorical { get; }
public ManualFinancialScreenKind? FinancialScreen { get; }
public StockMarket? Market { get; }
public string? StockName { get; }
public S5025ManualAudience? Audience { get; }
public IReadOnlyList<string>? HistoricalViNames { get; }
public string? ExpectedViVersion { get; }
public int ExpectedPageCount { get; }
public override string ToString() => $"Named manual restore candidate ({Kind})";
}
/// <summary>
/// C# equivalent of Web/named-manual-restore-workflow.js classify(). It accepts
/// only the historical seven-field PList grammar and the canonical grammar
/// emitted after a fresh materialization. It performs no I/O.
/// </summary>
public static class LegacyNamedManualRestoreClassifier
{
private const int ViPageSize = 5;
private const int MaximumViItems = 100;
public static LegacyNamedManualRestoreCandidate? Classify(LegacyPListAuditRow row)
{
ArgumentNullException.ThrowIfNull(row);
return ClassifyNetSell(row) ?? ClassifyVi(row) ?? ClassifyFinancial(row);
}
private static LegacyNamedManualRestoreCandidate? ClassifyNetSell(
LegacyPListAuditRow row)
{
if (row.PageTextForDisplay != "1/1" || row.DataCode.Length != 0)
{
return null;
}
if (TryCanonicalAudience(row.GroupCode, out var canonical) &&
row.Subject == "INDEX" &&
row.GraphicType == "MANUAL_NET_SELL" &&
row.Subtype == "MANUAL_NET_SELL")
{
return new LegacyNamedManualRestoreCandidate(
row.ItemIndex,
LegacyNamedManualRestoreKind.NetSell,
isHistorical: false,
audience: canonical);
}
if (TryHistoricalAudience(row.GroupCode, out var historical) &&
row.Subject == "지수" &&
row.GraphicType == "순매도 상위" &&
row.Subtype == "순매도 상위")
{
return new LegacyNamedManualRestoreCandidate(
row.ItemIndex,
LegacyNamedManualRestoreKind.NetSell,
isHistorical: true,
audience: historical);
}
return null;
}
private static LegacyNamedManualRestoreCandidate? ClassifyVi(
LegacyPListAuditRow row)
{
if (!TryPageCount(row.PageTextForDisplay, out var pageCount))
{
return null;
}
if (row.GroupCode == "PAGED_VI" &&
row.Subject == TrustedViManualReference.SubjectSentinel &&
row.GraphicType == "INPUT_ORDER" &&
row.Subtype == "CURRENT" &&
TrustedViManualReference.IsRowVersion(row.DataCode))
{
return new LegacyNamedManualRestoreCandidate(
row.ItemIndex,
LegacyNamedManualRestoreKind.Vi,
isHistorical: false,
expectedViVersion: row.DataCode,
expectedPageCount: pageCount);
}
if (row.GroupCode.Length != 0 ||
row.GraphicType != "5단 표그래프" ||
row.Subtype != "VI 발동(수동)" ||
row.DataCode.Length != 0 ||
!TryHistoricalViNames(row.Subject, out var names) ||
pageCount != DivideRoundUp(names.Count, ViPageSize))
{
return null;
}
return new LegacyNamedManualRestoreCandidate(
row.ItemIndex,
LegacyNamedManualRestoreKind.Vi,
isHistorical: true,
historicalViNames: names,
expectedPageCount: pageCount);
}
private static LegacyNamedManualRestoreCandidate? ClassifyFinancial(
LegacyPListAuditRow row)
{
if (row.PageTextForDisplay != "1/1" ||
row.Subtype.Length != 0 ||
row.DataCode.Length != 0 ||
!IsTrimmedVisible(row.Subject, 200))
{
return null;
}
var canonicalMarket = TryCanonicalMarket(row.GroupCode, out var canonical);
var historicalMarket = TryHistoricalMarket(row.GroupCode, out var historical);
foreach (var contract in ManualFinancialCutContracts.All)
{
if (canonicalMarket && row.GraphicType == contract.CanonicalGraphicType)
{
return Financial(row, contract.Screen, canonical, isHistorical: false);
}
if (historicalMarket && row.GraphicType == contract.OriginalGraphicType)
{
return Financial(row, contract.Screen, historical, isHistorical: true);
}
}
return null;
}
private static LegacyNamedManualRestoreCandidate Financial(
LegacyPListAuditRow row,
ManualFinancialScreenKind screen,
StockMarket market,
bool isHistorical) =>
new(
row.ItemIndex,
LegacyNamedManualRestoreKind.Financial,
isHistorical,
financialScreen: screen,
market: market,
stockName: row.Subject);
private static bool TryCanonicalAudience(
string value,
out S5025ManualAudience audience)
{
audience = value switch
{
"INDIVIDUAL" => S5025ManualAudience.Individual,
"FOREIGN" => S5025ManualAudience.Foreign,
"INSTITUTION" => S5025ManualAudience.Institution,
_ => default
};
return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION";
}
private static bool TryHistoricalAudience(
string value,
out S5025ManualAudience audience)
{
audience = value switch
{
"개인 순매도 상위(수동)" => S5025ManualAudience.Individual,
"외국인 순매도 상위(수동)" => S5025ManualAudience.Foreign,
"기관 순매도 상위(수동)" => S5025ManualAudience.Institution,
_ => default
};
return value is
"개인 순매도 상위(수동)" or
"외국인 순매도 상위(수동)" or
"기관 순매도 상위(수동)";
}
private static bool TryCanonicalMarket(string value, out StockMarket market)
{
market = value switch
{
"KOSPI" => StockMarket.Kospi,
"KOSDAQ" => StockMarket.Kosdaq,
"NXT_KOSPI" => StockMarket.NxtKospi,
"NXT_KOSDAQ" => StockMarket.NxtKosdaq,
_ => default
};
return value is "KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ";
}
private static bool TryHistoricalMarket(string value, out StockMarket market)
{
market = value switch
{
"코스피" => StockMarket.Kospi,
"코스닥" => StockMarket.Kosdaq,
"코스피_NXT" => StockMarket.NxtKospi,
"코스닥_NXT" => StockMarket.NxtKosdaq,
_ => default
};
return value is "코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT";
}
private static bool TryPageCount(string value, out int pageCount)
{
pageCount = 0;
if (!value.StartsWith("1/", StringComparison.Ordinal) ||
!int.TryParse(value.AsSpan(2), out var parsed) ||
parsed is < 1 or > 20 ||
value != $"1/{parsed}")
{
return false;
}
pageCount = parsed;
return true;
}
private static bool TryHistoricalViNames(
string value,
out IReadOnlyList<string> names)
{
names = Array.Empty<string>();
if (!IsTrimmedVisible(value, LegacyPListReadAuditParser.MaximumListTextLength))
{
return false;
}
var values = value.Split(',', StringSplitOptions.None);
if (values.Length is < 1 or > MaximumViItems ||
values.Any(name => !IsTrimmedVisible(name, 200)))
{
return false;
}
names = Array.AsReadOnly(values);
return true;
}
private static bool IsTrimmedVisible(string value, int maximumLength) =>
value.Length is > 0 &&
value.Length <= maximumLength &&
value == value.Trim() &&
!value.Any(char.IsControl) &&
!value.Contains('\uFFFD');
private static int DivideRoundUp(int value, int divisor) =>
checked((value + divisor - 1) / divisor);
}
/// <summary>
/// Pure checks for the evidence gathered through production read boundaries.
/// No method opens a database or file and no result contains source text.
/// </summary>
public static class LegacyNamedManualFreshProof
{
private const int ViPageSize = 5;
public static string FinancialStockSearchQuery(
LegacyNamedManualRestoreCandidate candidate)
{
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
var stockName = candidate.StockName!;
if (candidate.Market is not (StockMarket.NxtKospi or StockMarket.NxtKosdaq))
{
return stockName;
}
const string suffix = "(NXT)";
if (!stockName.EndsWith(suffix, StringComparison.Ordinal) ||
stockName.Length == suffix.Length)
{
throw new ArgumentException(
"An NXT manual-financial identity requires its original display suffix.",
nameof(candidate));
}
var query = stockName[..^suffix.Length];
if (query.Length == 0 || query != query.Trim() || query.Contains(suffix))
{
throw new ArgumentException(
"An NXT manual-financial identity has an invalid search identity.",
nameof(candidate));
}
return query;
}
public static LegacyNamedManualFreshProofFailure EvaluateFinancial(
LegacyNamedManualRestoreCandidate candidate,
ManualFinancialSnapshot firstRead,
ManualFinancialSnapshot secondRead,
StockSearchResult stockSearch)
{
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
ArgumentNullException.ThrowIfNull(firstRead);
ArgumentNullException.ThrowIfNull(secondRead);
ArgumentNullException.ThrowIfNull(stockSearch);
var identity = new ManualFinancialIdentity(
candidate.FinancialScreen!.Value,
candidate.StockName!);
if (firstRead.Record.Identity != identity ||
secondRead.Record.Identity != identity ||
!string.Equals(firstRead.RowVersion, secondRead.RowVersion, StringComparison.Ordinal))
{
return LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged;
}
if (stockSearch.IsTruncated ||
!string.Equals(
stockSearch.Query,
FinancialStockSearchQuery(candidate),
StringComparison.Ordinal))
{
return LegacyNamedManualFreshProofFailure.FinancialStockSearchTruncated;
}
var matches = stockSearch.Items
.Where(item =>
item.Market == candidate.Market &&
string.Equals(item.Name, candidate.StockName, StringComparison.Ordinal))
.ToArray();
if (matches.Length != 1)
{
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
}
try
{
var verified = ManualFinancialCutContracts.VerifyStock(
identity,
stockSearch.Items,
candidate.Market!.Value,
matches[0].Code);
var selection = ManualFinancialCutContracts.CreateSelection(secondRead, verified);
var contract = ManualFinancialCutContracts.Get(identity.Screen);
if (selection.BuilderKey != contract.BuilderKey ||
selection.CutCode != contract.CutCode ||
selection.CurrentPage != 1 ||
selection.TotalPages != 1 ||
selection.Selection.GroupCode != CanonicalMarket(candidate.Market.Value) ||
selection.Selection.Subject != candidate.StockName ||
selection.Selection.GraphicType != contract.CanonicalGraphicType ||
selection.Selection.Subtype.Length != 0 ||
selection.Selection.DataCode.Length != 0)
{
return LegacyNamedManualFreshProofFailure.FinancialSelectionMismatch;
}
}
catch (ArgumentException)
{
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
}
return LegacyNamedManualFreshProofFailure.None;
}
public static LegacyNamedManualFreshProofFailure EvaluateNetSell(
LegacyNamedManualRestoreCandidate candidate,
IReadOnlyList<S5025TrustedManualRow> rows)
{
RequireKind(candidate, LegacyNamedManualRestoreKind.NetSell);
if (rows is null || rows.Count != 5 || rows.Any(row =>
row is null ||
!ValidManualCell(row.LeftName) ||
!ValidManualCell(row.LeftAmount) ||
!ValidManualCell(row.RightName) ||
!ValidManualCell(row.RightAmount)))
{
return LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid;
}
return LegacyNamedManualFreshProofFailure.None;
}
public static LegacyNamedManualFreshProofFailure EvaluateVi(
LegacyNamedManualRestoreCandidate candidate,
TrustedViStockNameSnapshot snapshot)
{
RequireKind(candidate, LegacyNamedManualRestoreKind.Vi);
if (snapshot is null ||
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion) ||
snapshot.StockNames is null ||
snapshot.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount ||
snapshot.StockNames.Any(name =>
name is null ||
name.Length is < 1 or > 200 ||
name != name.Trim() ||
name.Contains(',') ||
name.Any(char.IsControl)))
{
return LegacyNamedManualFreshProofFailure.ViSnapshotInvalid;
}
if (candidate.ExpectedViVersion is not null &&
!string.Equals(
candidate.ExpectedViVersion,
snapshot.RowVersion,
StringComparison.Ordinal))
{
return LegacyNamedManualFreshProofFailure.ViSnapshotVersionChanged;
}
if (candidate.HistoricalViNames is not null &&
!candidate.HistoricalViNames.SequenceEqual(
snapshot.StockNames,
StringComparer.Ordinal))
{
return LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged;
}
var pageCount = checked((snapshot.StockNames.Count + ViPageSize - 1) / ViPageSize);
return pageCount == candidate.ExpectedPageCount
? LegacyNamedManualFreshProofFailure.None
: LegacyNamedManualFreshProofFailure.ViPageCountChanged;
}
private static void RequireKind(
LegacyNamedManualRestoreCandidate candidate,
LegacyNamedManualRestoreKind expected)
{
ArgumentNullException.ThrowIfNull(candidate);
if (candidate.Kind != expected)
{
throw new ArgumentException("The manual restore candidate kind is invalid.", nameof(candidate));
}
}
private static bool ValidManualCell(string value) =>
value is not null &&
value.Length <= 256 &&
!value.Any(char.IsControl);
private static string CanonicalMarket(StockMarket market) => market switch
{
StockMarket.Kospi => "KOSPI",
StockMarket.Kosdaq => "KOSDAQ",
StockMarket.NxtKospi => "NXT_KOSPI",
StockMarket.NxtKosdaq => "NXT_KOSDAQ",
_ => throw new ArgumentOutOfRangeException(nameof(market))
};
}

View File

@@ -0,0 +1,341 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// A closed pair of current and persisted NXT theme codes used only by the
/// read-only restore diagnostic. Titles and stock identities never cross this
/// boundary.
/// </summary>
public sealed record LegacyNxtThemeJoinAuditIdentity(
string CurrentThemeCode,
string StoredThemeCode);
/// <summary>
/// Aggregate evidence for one current theme code. No theme or stock identifier
/// is returned, so callers can safely log only cardinalities.
/// </summary>
public sealed record LegacyNxtThemeJoinAuditEvidence(
int InputIndex,
int RawCurrentItemCount,
int RawStoredItemCount,
int CurrentOriginalJoinCount,
int CurrentNonStoppedJoinCount,
int CurrentLiveJoinCount,
int StoredOriginalJoinCount,
int StoredNonStoppedJoinCount,
int StoredLiveJoinCount,
int CurrentExactJoinCount,
int CurrentStripBothJoinCount,
int CurrentRightSixJoinCount,
int CurrentValidItemCodeCount,
int CurrentKospiMasterJoinCount,
int CurrentKosdaqMasterJoinCount,
int CurrentKospiOnlineJoinCount,
int CurrentKosdaqOnlineJoinCount);
public sealed record LegacyNxtThemeJoinAuditResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> Themes);
/// <summary>
/// Executes only bounded aggregate MariaDB reads. The original s5074, s5077,
/// and s5088 NXT loaders all use SUBSTRING(SB_I_CODE, 2, 12) = F_STOCK_CODE;
/// the other joins are diagnostic evidence only and are never used to resolve
/// or play a scene.
/// </summary>
public sealed partial class LegacyNxtThemeJoinAuditService
{
public const int MaximumThemes = 100;
public const string QueryName = "LEGACY_NXT_THEME_JOIN_AUDIT";
private static readonly string[] Columns =
[
"RAW_CURRENT_ITEM_COUNT",
"RAW_STORED_ITEM_COUNT",
"CURRENT_ORIGINAL_JOIN_COUNT",
"CURRENT_NON_STOPPED_JOIN_COUNT",
"CURRENT_LIVE_JOIN_COUNT",
"STORED_ORIGINAL_JOIN_COUNT",
"STORED_NON_STOPPED_JOIN_COUNT",
"STORED_LIVE_JOIN_COUNT",
"CURRENT_EXACT_JOIN_COUNT",
"CURRENT_STRIP_BOTH_JOIN_COUNT",
"CURRENT_RIGHT_SIX_JOIN_COUNT",
"CURRENT_VALID_ITEM_CODE_COUNT",
"CURRENT_KOSPI_MASTER_JOIN_COUNT",
"CURRENT_KOSDAQ_MASTER_JOIN_COUNT",
"CURRENT_KOSPI_ONLINE_JOIN_COUNT",
"CURRENT_KOSDAQ_ONLINE_JOIN_COUNT"
];
private const string Sql = """
SELECT
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
RAW_CURRENT_ITEM_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
RAW_STORED_ITEM_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
CURRENT_ORIGINAL_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND q.F_STOP_GUBUN = 'N') AS CHAR)
CURRENT_NON_STOPPED_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND q.F_STOP_GUBUN = 'N'
AND q.F_CURR_PRICE != 0) AS CHAR)
CURRENT_LIVE_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
STORED_ORIGINAL_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
AND q.F_STOP_GUBUN = 'N') AS CHAR)
STORED_NON_STOPPED_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
AND q.F_STOP_GUBUN = 'N'
AND q.F_CURR_PRICE != 0) AS CHAR)
STORED_LIVE_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON i.SB_I_CODE = q.F_STOCK_CODE
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
CURRENT_EXACT_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) =
SUBSTRING(q.F_STOCK_CODE, 2, 12)
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
CURRENT_STRIP_BOTH_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON RIGHT(i.SB_I_CODE, 6) = RIGHT(q.F_STOCK_CODE, 6)
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
CURRENT_RIGHT_SIX_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND i.SB_I_CODE REGEXP BINARY '^[XT][A-Za-z0-9]{1,12}$') AS CHAR)
CURRENT_VALID_ITEM_CODE_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND EXISTS (
SELECT 1
FROM N_STOCK q
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
)) AS CHAR)
CURRENT_KOSPI_MASTER_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND EXISTS (
SELECT 1
FROM N_KOSDAQ_STOCK q
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
)) AS CHAR)
CURRENT_KOSDAQ_MASTER_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND EXISTS (
SELECT 1
FROM N_ONLINE q
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
)) AS CHAR)
CURRENT_KOSPI_ONLINE_JOIN_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
AND EXISTS (
SELECT 1
FROM N_KOSDAQ_ONLINE q
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
)) AS CHAR)
CURRENT_KOSDAQ_ONLINE_JOIN_COUNT
FROM (
SELECT @current_theme_code CURRENT_THEME_CODE,
@stored_theme_code STORED_THEME_CODE
) p
""";
private readonly IDataQueryExecutor _executor;
private readonly TimeProvider _timeProvider;
public LegacyNxtThemeJoinAuditService(IDataQueryExecutor executor)
: this(executor, TimeProvider.System)
{
}
internal LegacyNxtThemeJoinAuditService(
IDataQueryExecutor executor,
TimeProvider timeProvider)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<LegacyNxtThemeJoinAuditResult> AuditAsync(
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(identities);
if (identities.Count is < 1 or > MaximumThemes)
{
throw new ArgumentOutOfRangeException(
nameof(identities),
$"A join audit must contain 1 through {MaximumThemes} themes.");
}
var currentCodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var identity in identities)
{
var current = identity?.CurrentThemeCode;
var stored = identity?.StoredThemeCode;
if (!ThemeCodePattern().IsMatch(current ?? string.Empty) ||
!ThemeCodePattern().IsMatch(stored ?? string.Empty) ||
!currentCodes.Add(current!))
{
throw new ArgumentException(
"NXT join audit identities must have unique canonical current codes.",
nameof(identities));
}
}
var evidence = new List<LegacyNxtThemeJoinAuditEvidence>(identities.Count);
for (var index = 0; index < identities.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var identity = identities[index];
var spec = new DataQuerySpec(
Sql,
[
new DataQueryParameter(
"current_theme_code",
identity.CurrentThemeCode,
DbType.String),
new DataQueryParameter(
"stored_theme_code",
identity.StoredThemeCode,
DbType.String)
]);
spec.ValidateFor(DataSourceKind.MariaDb);
var table = await _executor.ExecuteAsync(
DataSourceKind.MariaDb,
QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
evidence.Add(MapEvidence(table, index));
}
return new LegacyNxtThemeJoinAuditResult(
_timeProvider.GetLocalNow(),
evidence.AsReadOnly());
}
private static LegacyNxtThemeJoinAuditEvidence MapEvidence(
DataTable? table,
int inputIndex)
{
if (table is null || table.Columns.Count != Columns.Length || table.Rows.Count != 1)
{
throw InvalidData();
}
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 InvalidData();
}
}
var counts = table.Rows[0].ItemArray.Select(ReadCount).ToArray();
if (counts[2] > counts[0] || counts[3] > counts[2] || counts[4] > counts[3] ||
counts[5] > counts[1] || counts[6] > counts[5] || counts[7] > counts[6] ||
counts[8] > counts[0] || counts[9] > counts[0] || counts[10] > counts[0] ||
counts[11] > counts[0] || counts[12] > counts[0] || counts[13] > counts[0] ||
counts[14] > counts[0] || counts[15] > counts[0] ||
counts[12] + counts[13] > counts[0] ||
counts[14] + counts[15] > counts[0])
{
throw InvalidData();
}
return new LegacyNxtThemeJoinAuditEvidence(
inputIndex,
counts[0],
counts[1],
counts[2],
counts[3],
counts[4],
counts[5],
counts[6],
counts[7],
counts[8],
counts[9],
counts[10],
counts[11],
counts[12],
counts[13],
counts[14],
counts[15]);
}
private static int ReadCount(object? value)
{
if (value is not string text ||
!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var count) ||
count is < 0 or > 1_000_000 ||
!string.Equals(text, count.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
{
throw InvalidData();
}
return count;
}
private static InvalidDataException InvalidData() =>
new("The NXT theme join audit returned invalid aggregate data.");
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
private static partial Regex ThemeCodePattern();
}

View File

@@ -0,0 +1,524 @@
#nullable enable
using System.Globalization;
using System.Text;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// The exact seven values written by the original PList.data_save method.
/// RawListText and Fields are retained so a read-only audit can prove that
/// parsing did not normalize any operator text. PageTextForDisplay is the one
/// original load-time transformation: ASCII spaces are removed from field 5.
/// </summary>
public sealed record LegacyPListAuditRow(
string ProgramCode,
string ProgramTitle,
int ItemIndex,
bool IsEnabled,
IReadOnlyList<string> Fields,
string RawListText,
string PageTextForDisplay)
{
public string GroupCode => Fields[1];
public string Subject => Fields[2];
public string GraphicType => Fields[3];
public string Subtype => Fields[4];
public string RawPageText => Fields[5];
public string DataCode => Fields[6];
public bool IsLossless =>
string.Equals(string.Join('^', Fields), RawListText, StringComparison.Ordinal);
}
public enum LegacyPListAuditParseFailure
{
InvalidProgramCode,
InvalidProgramTitle,
InvalidItemIndex,
InvalidListText,
InvalidFieldCount,
InvalidEnabledField,
UnsafeText,
ReplacementCharacter,
MojibakeSignal
}
public enum LegacyPListAuditRoute
{
ExistingClosedMapper,
IndustryFixedClosedMapper,
ComparisonFreshIdentity,
ForeignIndexCandleFreshIdentity,
NxtThemeFreshIdentity,
KrxThemeFreshIdentity,
ForeignStockFreshIdentity,
ManualFreshMaterialization,
Unmapped
}
public sealed record LegacyPListAuditParseResult(
LegacyPListAuditRow? Row,
LegacyPListAuditParseFailure? Failure)
{
public bool IsSuccess => Row is not null && Failure is null;
}
/// <summary>
/// Parser used only for the read-only production-data audit. It intentionally
/// accepts the original PList grammar before any current scene mapping is
/// attempted and never includes source text in exception or diagnostic output.
/// </summary>
public static class LegacyPListReadAuditParser
{
public const int MaximumProgramTitleLength = 128;
public const int MaximumListTextLength = 4_000;
public const int MaximumItems = LegacyNamedPlaylistPersistenceService.MaximumItems;
public static LegacyPListAuditParseResult Parse(
string? programCode,
string? programTitle,
string? itemIndex,
string? listText)
{
if (programCode is null || programCode.Length != 8 ||
programCode.Any(static value => !char.IsAsciiDigit(value)))
{
return Failed(LegacyPListAuditParseFailure.InvalidProgramCode);
}
if (!IsCanonicalText(programTitle, MaximumProgramTitleLength, allowEmpty: false))
{
return Failed(programTitle?.Contains('\uFFFD') == true
? LegacyPListAuditParseFailure.ReplacementCharacter
: ContainsMojibakeSignal(programTitle)
? LegacyPListAuditParseFailure.MojibakeSignal
: LegacyPListAuditParseFailure.InvalidProgramTitle);
}
if (itemIndex is null ||
!int.TryParse(
itemIndex,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var parsedIndex) ||
parsedIndex is < 0 or >= MaximumItems ||
!string.Equals(
itemIndex,
parsedIndex.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
return Failed(LegacyPListAuditParseFailure.InvalidItemIndex);
}
if (listText is null || listText.Length is < 1 or > MaximumListTextLength)
{
return Failed(LegacyPListAuditParseFailure.InvalidListText);
}
if (listText.Contains('\uFFFD'))
{
return Failed(LegacyPListAuditParseFailure.ReplacementCharacter);
}
if (ContainsMojibakeSignal(listText))
{
return Failed(LegacyPListAuditParseFailure.MojibakeSignal);
}
if (!IsSafeText(listText))
{
return Failed(LegacyPListAuditParseFailure.UnsafeText);
}
var fields = listText.Split('^', StringSplitOptions.None);
if (fields.Length != 7)
{
return Failed(LegacyPListAuditParseFailure.InvalidFieldCount);
}
if (fields[0] is not ("0" or "1"))
{
return Failed(LegacyPListAuditParseFailure.InvalidEnabledField);
}
// A caret cannot occur inside a field after an exact seven-way split.
// Retain empty strings and every code unit exactly as the original row.
if (fields.Any(static field => !IsSafeText(field)))
{
return Failed(LegacyPListAuditParseFailure.UnsafeText);
}
var frozenFields = Array.AsReadOnly(fields);
var row = new LegacyPListAuditRow(
programCode,
programTitle!,
parsedIndex,
fields[0] == "1",
frozenFields,
listText,
fields[5].Replace(" ", string.Empty, StringComparison.Ordinal));
return row.IsLossless
? new LegacyPListAuditParseResult(row, Failure: null)
: Failed(LegacyPListAuditParseFailure.InvalidListText);
}
public static bool ContainsMojibakeSignal(string? value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
// U+FFFD is reported separately. These code points and byte-order
// marker are reliable signs of UTF-8/legacy-codepage damage in the
// Korean operator data. Literal question marks are not rejected because
// they are valid operator text and are not proof of corruption alone.
foreach (var character in value)
{
if (character is '\u00C2' or '\u00C3' or '\u00EC' or '\u00ED' or
'\u00EA' or '\u00EB' or '\uFEFF')
{
return true;
}
}
return value.Contains("â€", StringComparison.Ordinal) ||
value.Contains("", StringComparison.Ordinal);
}
private static LegacyPListAuditParseResult Failed(
LegacyPListAuditParseFailure failure) =>
new(Row: null, failure);
private static bool IsCanonicalText(
string? value,
int maximumLength,
bool allowEmpty)
{
if (value is null || value.Length > maximumLength ||
(!allowEmpty && value.Length == 0) ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains('\uFFFD') || ContainsMojibakeSignal(value))
{
return false;
}
return IsSafeText(value);
}
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
{
if (char.IsHighSurrogate(value[index]))
{
if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1]))
{
return false;
}
index++;
}
else if (char.IsLowSurrogate(value[index]))
{
return false;
}
}
foreach (var rune in value.EnumerateRunes())
{
var category = Rune.GetUnicodeCategory(rune);
if (Rune.IsControl(rune) || category is
UnicodeCategory.Format or
UnicodeCategory.Surrogate or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
}
/// <summary>
/// Side-effect-free evidence checks shared by the database smoke audit and its
/// fixtures. This is deliberately a route classifier, not a resolver: routes
/// that require current database or trusted-file evidence remain unplayable
/// until the corresponding service reports a unique materialization result.
/// </summary>
public static class LegacyPListReadAuditEvidence
{
public static bool HasContiguousIndexes(IReadOnlyList<LegacyPListAuditRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
for (var index = 0; index < rows.Count; index++)
{
if (rows[index] is null || rows[index].ItemIndex != index)
{
return false;
}
}
return true;
}
public static bool MatchesPersistenceResult(
LegacyPListAuditRow raw,
NamedPlaylistStoredItem stored)
{
ArgumentNullException.ThrowIfNull(raw);
ArgumentNullException.ThrowIfNull(stored);
return raw.ItemIndex == stored.ItemIndex &&
raw.IsEnabled == stored.IsEnabled &&
string.Equals(raw.GroupCode, stored.Selection.GroupCode, StringComparison.Ordinal) &&
string.Equals(raw.Subject, stored.Selection.Subject, StringComparison.Ordinal) &&
string.Equals(raw.GraphicType, stored.Selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(raw.Subtype, stored.Selection.Subtype, StringComparison.Ordinal) &&
string.Equals(raw.DataCode, stored.Selection.DataCode, StringComparison.Ordinal) &&
string.Equals(
raw.PageTextForDisplay,
stored.Page?.ToString() ?? string.Empty,
StringComparison.Ordinal);
}
public static LegacyPListAuditRoute ClassifyRoute(LegacyPListAuditRow row)
{
ArgumentNullException.ThrowIfNull(row);
var comparison = ToComparisonRow(row);
if (LegacyNamedComparisonRestoreService.IsCandidate(comparison))
{
return LegacyPListAuditRoute.ComparisonFreshIdentity;
}
var foreignIndex = ToForeignIndexCandleRow(row);
if (LegacyForeignIndexCandleRestoreService.IsCandidate(foreignIndex))
{
return LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity;
}
var nxt = ToNxtThemeRow(row);
if (LegacyNamedNxtThemeRestoreService.IsCandidate(nxt))
{
return LegacyPListAuditRoute.NxtThemeFreshIdentity;
}
if (IsIndustryFixed(row))
{
return LegacyPListAuditRoute.IndustryFixedClosedMapper;
}
if (string.Equals(row.GroupCode, "테마", StringComparison.Ordinal))
{
return LegacyPListAuditRoute.KrxThemeFreshIdentity;
}
if (row.GroupCode == "해외종목")
{
return LegacyPListAuditRoute.ForeignStockFreshIdentity;
}
// The identity-bearing canonical format is already handled by the
// current overseas workflow. Only its one unambiguous current-price
// action is a closed seven-field mapping; the five candle periods share
// the same selection fields and therefore remain deliberately unmapped.
if (row.GroupCode == "FOREIGN_STOCK")
{
return IsCanonicalForeignStockCurrent(row)
? LegacyPListAuditRoute.ExistingClosedMapper
: LegacyPListAuditRoute.Unmapped;
}
if (IsManualCandidate(row))
{
return LegacyPListAuditRoute.ManualFreshMaterialization;
}
return IsExistingClosedMapper(row)
? LegacyPListAuditRoute.ExistingClosedMapper
: LegacyPListAuditRoute.Unmapped;
}
public static LegacyNamedComparisonRestoreRow ToComparisonRow(LegacyPListAuditRow row) =>
new(
row.ItemIndex,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode);
public static LegacyForeignIndexCandleRestoreRow ToForeignIndexCandleRow(
LegacyPListAuditRow row) =>
new(
row.ItemIndex,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode);
public static LegacyNamedNxtThemeRestoreRow ToNxtThemeRow(LegacyPListAuditRow row) =>
new(
row.ItemIndex,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode);
private static bool IsIndustryFixed(LegacyPListAuditRow row)
{
if (row.Subtype.Length != 0 || row.DataCode.Length != 0 ||
row.GraphicType is not ("5단 표그래프" or "네모그래프" or "섹터지수"))
{
return false;
}
return row.GroupCode switch
{
"업종_코스피" => string.Equals(row.Subject, "코스피", StringComparison.Ordinal),
"업종_코스닥" => string.Equals(row.Subject, "코스닥", StringComparison.Ordinal),
_ => false
};
}
private static bool IsManualCandidate(LegacyPListAuditRow row) =>
LegacyNamedManualRestoreClassifier.Classify(row) is not null;
private static bool IsCanonicalForeignStockCurrent(LegacyPListAuditRow row)
{
if (row.PageTextForDisplay != "1/1" ||
row.Subject.Length is < 1 or > 200 ||
row.Subject != row.Subject.Trim() ||
row.Subject.Contains('|') ||
row.GraphicType != "1열판기본" ||
row.Subtype != "CURRENT")
{
return false;
}
var parts = row.DataCode.Split('|', StringSplitOptions.None);
return parts.Length == 3 &&
LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) &&
parts[1] is "US" or "TW" &&
parts[2] == "1";
}
private static bool IsExistingClosedMapper(LegacyPListAuditRow row) =>
IsLegacyStock(row) || IsLegacyExpert(row) || IsCanonicalDefault(row);
private static bool IsLegacyStock(LegacyPListAuditRow row)
{
if (row.PageTextForDisplay != "1/1" || row.Subject.Length is < 1 or > 128 ||
row.DataCode.Length is < 1 or > 32 ||
row.DataCode.Any(static value => !char.IsAsciiLetterOrDigit(value)))
{
return false;
}
var isNxt = row.GroupCode is "코스피_NXT" or "코스닥_NXT";
if (row.GroupCode is not ("코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT"))
{
return false;
}
var label = row.Subtype.Length == 0
? row.GraphicType
: string.Concat(row.GraphicType, "_", row.Subtype);
if (isNxt)
{
return label == "1열판기본_현재가";
}
return StockLegacyLabels.Contains(label);
}
private static bool IsLegacyExpert(LegacyPListAuditRow row) =>
row.PageTextForDisplay.StartsWith("1/", StringComparison.Ordinal) &&
row.GroupCode == "전문가 추천" && row.Subject.Length is > 0 and <= 128 &&
row.GraphicType == "5단 표그래프" && row.Subtype == "현재가" &&
row.DataCode.Length == 4 && row.DataCode.All(char.IsAsciiDigit);
private static bool IsCanonicalDefault(LegacyPListAuditRow row) =>
CanonicalDefaults.Contains(new SelectionKey(
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.DataCode));
private static readonly HashSet<string> StockLegacyLabels = new(StringComparer.Ordinal)
{
"1열판기본_예상체결가",
"1열판기본_현재가",
"1열판기본_시간외단일가",
"1열판상세_시가",
"1열판상세_액면가",
"1열판상세_PBR",
"1열판상세_거래량",
"수익률그래프_5일",
"수익률그래프_20일",
"수익률그래프_60일",
"수익률그래프_120일",
"수익률그래프_240일",
"캔들그래프_일봉",
"캔들그래프_5일",
"캔들그래프_20일",
"캔들그래프_60일",
"캔들그래프_120일",
"캔들그래프_240일",
"캔들그래프(거래량)_일봉",
"캔들그래프(거래량)_5일",
"캔들그래프(거래량)_20일",
"캔들그래프(거래량)_60일",
"캔들그래프(거래량)_120일",
"캔들그래프(거래량)_240일",
"캔들그래프(예상체결가)_5일",
"캔들그래프(예상체결가)_20일",
"캔들그래프(예상체결가)_60일",
"캔들그래프(예상체결가)_120일",
"캔들그래프(예상체결가)_240일",
"호가창_표그래프",
"거래원_표그래프"
};
private static readonly HashSet<SelectionKey> CanonicalDefaults = new()
{
new("ALL", "INDEX", string.Empty, "UnitedStatesIndices", string.Empty),
new("ALL", "INDEX", string.Empty, "Cotton", string.Empty),
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", string.Empty),
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", string.Empty),
new("INDEX", "FUTURES,Kospi", string.Empty, "CURRENT", string.Empty),
new("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", string.Empty, string.Empty),
new("ALL", "INDEX", string.Empty, string.Empty, string.Empty),
new("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", string.Empty),
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", string.Empty),
new("ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", string.Empty),
new("EXCHANGE_RATE", "원달러", string.Empty, "20일", string.Empty),
new("FOREIGN_INDEX", "DOW", string.Empty, "DowJones", string.Empty),
new("ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", string.Empty),
new("ALL", "KOSPI", string.Empty, string.Empty, string.Empty),
new("INDEX", "Kospi,Kosdaq", string.Empty, "CURRENT", string.Empty)
};
private readonly record struct SelectionKey(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
}

View File

@@ -2,6 +2,7 @@
using System.Data;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
@@ -459,13 +460,11 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
private static bool IsSafeText(string value)
{
for (var index = 0; index < value.Length; index++)
foreach (var rune in value.EnumerateRunes())
{
var character = value[index];
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
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)
{