feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -0,0 +1,418 @@
#nullable enable
using System.Collections.ObjectModel;
using System.Data;
namespace MMoneyCoderSharp.Data;
/// <summary>
/// Describes one provider-neutral input parameter for a read query.
/// Names are logical identifiers; the SQL supplies the provider marker
/// (<c>:name</c> for Oracle or <c>@name</c> for MariaDB).
/// </summary>
public sealed class DataQueryParameter
{
private const int MaximumNameLength = 128;
public DataQueryParameter(string name, object? value, DbType? dbType = null)
{
if (!IsValidName(name))
{
throw new ArgumentException(
"A query parameter name is invalid.",
nameof(name));
}
if (dbType.HasValue && !Enum.IsDefined(dbType.Value))
{
throw new ArgumentOutOfRangeException(
nameof(dbType),
"A query parameter database type is invalid.");
}
Name = name;
Value = value;
DbType = dbType;
}
public string Name { get; }
public object? Value { get; }
public DbType? DbType { get; }
public override string ToString() => "Data query parameter";
internal static bool IsValidName(string? name)
{
if (string.IsNullOrEmpty(name) || name.Length > MaximumNameLength)
{
return false;
}
if (!IsAsciiLetter(name[0]) && name[0] != '_')
{
return false;
}
for (var index = 1; index < name.Length; index++)
{
var character = name[index];
if (!IsAsciiLetter(character) && !char.IsAsciiDigit(character) && character != '_')
{
return false;
}
}
return true;
}
private static bool IsAsciiLetter(char character) =>
character is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
}
/// <summary>
/// An immutable, validated SELECT/WITH query and its input parameters.
/// The deliberately conservative validation fails closed for ambiguous SQL.
/// Each logical parameter must occur exactly once; use distinct logical names
/// when one value is intentionally bound at multiple SQL positions.
/// </summary>
public sealed class DataQuerySpec
{
private const int MaximumSqlLength = 65_536;
private static readonly HashSet<string> StateChangingTokens = new(
[
"INSERT",
"UPDATE",
"DELETE",
"REPLACE",
"MERGE",
"CALL",
"EXEC",
"EXECUTE",
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"GRANT",
"REVOKE",
"INTO",
"LOCK",
"FUNCTION",
"PROCEDURE",
"BEGIN",
"DECLARE",
"PRAGMA"
],
StringComparer.OrdinalIgnoreCase);
private readonly ReadOnlyCollection<ParameterReference> _references;
public DataQuerySpec(string sql)
: this(sql, Array.Empty<DataQueryParameter>())
{
}
public DataQuerySpec(string sql, IEnumerable<DataQueryParameter> parameters)
{
if (string.IsNullOrWhiteSpace(sql) || sql.Length > MaximumSqlLength || sql.IndexOf('\0') >= 0)
{
throw InvalidSql(nameof(sql));
}
ArgumentNullException.ThrowIfNull(parameters);
var suppliedParameters = parameters.ToArray();
if (suppliedParameters.Any(static parameter => parameter is null))
{
throw new ArgumentException(
"A query parameter collection is invalid.",
nameof(parameters));
}
var parametersByName = new Dictionary<string, DataQueryParameter>(
StringComparer.OrdinalIgnoreCase);
foreach (var parameter in suppliedParameters)
{
if (!parametersByName.TryAdd(parameter.Name, parameter))
{
throw new ArgumentException(
"A query parameter collection is invalid.",
nameof(parameters));
}
}
var parsed = Parse(sql);
if (parsed.Tokens.Count == 0 ||
(!string.Equals(parsed.Tokens[0], "SELECT", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parsed.Tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)) ||
parsed.Tokens.Any(StateChangingTokens.Contains))
{
throw InvalidSql(nameof(sql));
}
var referencedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var reference in parsed.References)
{
if (!referencedNames.Add(reference.Name) || !parametersByName.ContainsKey(reference.Name))
{
throw InvalidSql(nameof(sql));
}
}
if (referencedNames.Count != parametersByName.Count)
{
throw new ArgumentException(
"A query parameter collection does not match the query.",
nameof(parameters));
}
// Preserve SQL placeholder order. This also keeps positional Oracle binding
// deterministic without constructing provider-specific parameter objects.
var orderedParameters = parsed.References
.Select(reference => parametersByName[reference.Name])
.ToArray();
Sql = sql;
Parameters = Array.AsReadOnly(orderedParameters);
_references = Array.AsReadOnly(parsed.References.ToArray());
}
public string Sql { get; }
public IReadOnlyList<DataQueryParameter> Parameters { get; }
/// <summary>
/// Verifies that all placeholders use the selected provider's marker.
/// </summary>
public void ValidateFor(DataSourceKind source)
{
var expectedMarker = source switch
{
DataSourceKind.Oracle => ':',
DataSourceKind.MariaDb => '@',
_ => throw new ArgumentOutOfRangeException(
nameof(source),
"The query data source is invalid.")
};
if (_references.Any(reference => reference.Marker != expectedMarker))
{
throw InvalidSql(nameof(source));
}
}
public override string ToString() => "Parameterized read query";
private static ParsedSql Parse(string sql)
{
var tokens = new List<string>();
var references = new List<ParameterReference>();
for (var index = 0; index < sql.Length;)
{
var current = sql[index];
if (IsPortableSqlWhitespace(current))
{
index++;
continue;
}
if (current > 0x7f)
{
// Unquoted identifiers and whitespace differ between providers.
// Unicode remains supported inside quoted text and comments only.
throw InvalidSql(nameof(sql));
}
if (current == '-' && index + 1 < sql.Length && sql[index + 1] == '-')
{
if (index + 2 < sql.Length && !IsPortableLineCommentWhitespace(sql[index + 2]))
{
// MariaDB only recognizes -- as a comment when whitespace follows.
// Reject the cross-provider ambiguity instead of skipping SQL text.
throw InvalidSql(nameof(sql));
}
index += 2;
while (index < sql.Length && sql[index] is not '\r' and not '\n')
{
index++;
}
continue;
}
if (current == '/' && index + 1 < sql.Length && sql[index + 1] == '*')
{
if ((index + 2 < sql.Length && sql[index + 2] == '!') ||
(index + 3 < sql.Length &&
(sql[index + 2] is 'M' or 'm') &&
sql[index + 3] == '!'))
{
// MySQL/MariaDB execute version and /*M! comments as SQL.
throw InvalidSql(nameof(sql));
}
var commentEnd = sql.IndexOf("*/", index + 2, StringComparison.Ordinal);
if (commentEnd < 0)
{
throw InvalidSql(nameof(sql));
}
index = commentEnd + 2;
continue;
}
if (current is '\'' or '"' or '`')
{
if (current == '\'' &&
index > 0 &&
sql[index - 1] is 'Q' or 'q')
{
// Oracle alternative quoting has delimiter-dependent rules.
// Reject it instead of interpreting its body with standard quoting.
throw InvalidSql(nameof(sql));
}
index = SkipQuotedText(sql, index, current);
continue;
}
if (current == ';')
{
throw InvalidSql(nameof(sql));
}
if (current is ':' or '@')
{
if (index > 0 && IsAmbiguousPlaceholderBoundary(sql[index - 1]))
{
throw InvalidSql(nameof(sql));
}
var nameStart = index + 1;
if (nameStart >= sql.Length ||
(!IsAsciiLetter(sql[nameStart]) && sql[nameStart] != '_'))
{
throw InvalidSql(nameof(sql));
}
var nameEnd = nameStart + 1;
while (nameEnd < sql.Length && IsParameterNameCharacter(sql[nameEnd]))
{
nameEnd++;
}
if (nameEnd < sql.Length && !IsValidPlaceholderTerminator(sql[nameEnd]))
{
throw InvalidSql(nameof(sql));
}
var name = sql[nameStart..nameEnd];
if (!DataQueryParameter.IsValidName(name))
{
throw InvalidSql(nameof(sql));
}
references.Add(new ParameterReference(current, name));
index = nameEnd;
continue;
}
if (IsAsciiLetter(current) || current == '_')
{
var tokenEnd = index + 1;
while (tokenEnd < sql.Length && IsSqlTokenCharacter(sql[tokenEnd]))
{
tokenEnd++;
}
tokens.Add(sql[index..tokenEnd]);
index = tokenEnd;
continue;
}
if (!char.IsAsciiDigit(current) && !IsAllowedSqlPunctuation(current))
{
throw InvalidSql(nameof(sql));
}
index++;
}
return new ParsedSql(tokens, references);
}
private static int SkipQuotedText(string sql, int quoteStart, char quote)
{
for (var index = quoteStart + 1; index < sql.Length; index++)
{
if (sql[index] == '\\')
{
// MariaDB backslash escaping depends on SQL_MODE. Reject the
// ambiguity so executable text can never be mistaken for a literal.
throw InvalidSql(nameof(sql));
}
if (sql[index] != quote)
{
continue;
}
if (index + 1 < sql.Length && sql[index + 1] == quote)
{
index++;
continue;
}
return index + 1;
}
throw InvalidSql(nameof(sql));
}
private static bool IsParameterNameCharacter(char character) =>
IsAsciiLetter(character) || char.IsAsciiDigit(character) || character == '_';
private static bool IsSqlTokenCharacter(char character) =>
IsParameterNameCharacter(character) || character is '$' or '#';
private static bool IsPortableLineCommentWhitespace(char character) =>
IsPortableSqlWhitespace(character);
private static bool IsPortableSqlWhitespace(char character) =>
character is ' ' or '\t' or '\r' or '\n' or '\f';
private static bool IsAmbiguousPlaceholderBoundary(char character) =>
char.IsLetterOrDigit(character) ||
char.GetUnicodeCategory(character) is
System.Globalization.UnicodeCategory.ConnectorPunctuation or
System.Globalization.UnicodeCategory.NonSpacingMark or
System.Globalization.UnicodeCategory.SpacingCombiningMark or
System.Globalization.UnicodeCategory.Format ||
character is '$' or '#' or ':' or '@';
private static bool IsValidPlaceholderTerminator(char character) =>
IsPortableSqlWhitespace(character) ||
character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or
'=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or ';';
private static bool IsAllowedSqlPunctuation(char character) =>
character is ',' or ')' or '(' or '+' or '-' or '*' or '/' or '%' or
'=' or '<' or '>' or '!' or '|' or '&' or '^' or '.' or '~';
private static bool IsAsciiLetter(char character) =>
character is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
private static ArgumentException InvalidSql(string parameterName) =>
new("The read query is invalid or ambiguous.", parameterName);
private sealed record ParameterReference(char Marker, string Name);
private sealed record ParsedSql(
IReadOnlyList<string> Tokens,
IReadOnlyList<ParameterReference> References);
}

View File

@@ -25,6 +25,29 @@ public interface IDataQueryExecutor
string tableName,
string query,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes an immutable parameterized read query asynchronously.
/// Existing adapters remain source-compatible, but must opt in before
/// accepting a spec that contains parameters.
/// </summary>
Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
query.ValidateFor(source);
if (query.Parameters.Count > 0)
{
throw new NotSupportedException(
"This query executor does not support parameterized queries.");
}
return ExecuteAsync(source, tableName, query.Sql, cancellationToken);
}
}
/// <summary>

View File

@@ -31,7 +31,8 @@ public enum PlayoutOperation
Prepare,
TakeIn,
Next,
TakeOut
TakeOut,
UpdateOnAir
}
public enum PlayoutResultCode
@@ -67,6 +68,125 @@ public sealed record PlayoutField(
string? Value = null,
bool? IsVisible = null);
[Flags]
public enum PlayoutVectorComponents
{
X = 1,
Y = 2,
XY = X | Y,
Z = 4,
ZX = Z | X,
YZ = Y | Z,
XYZ = X | Y | Z
}
[Flags]
public enum PlayoutCropEdges
{
Left = 1,
Top = 2,
Right = 4,
Horizontal = Left | Right,
Bottom = 8,
Vertical = Top | Bottom,
All = Left | Top | Right | Bottom
}
[Flags]
public enum PlayoutAngleComponents
{
Start = 1,
End = 2,
All = Start | End
}
public readonly record struct PlayoutPoint(float X, float Y, float Z = 0);
/// <summary>
/// COM-neutral, ordered K3D mutation applied inside the scene transaction.
/// Concrete mutation types form the allowlist; arbitrary method names are never accepted.
/// </summary>
public abstract record PlayoutMutation;
public enum PlayoutMutationTiming
{
BeforeTransaction,
InTransaction
}
public sealed record PlayoutSetValue(string ObjectName, string Value) : PlayoutMutation;
public sealed record PlayoutSetAssetValue(string ObjectName, string AssetPath) : PlayoutMutation;
public sealed record PlayoutSetVisible(string ObjectName, bool IsVisible) : PlayoutMutation;
public sealed record PlayoutSetFaceColor(
string ObjectName,
int Red,
int Green,
int Blue,
int Alpha = 255) : PlayoutMutation;
public sealed record PlayoutSetPosition(
string ObjectName,
float X,
float Y,
float Z,
PlayoutVectorComponents Components) : PlayoutMutation;
public sealed record PlayoutSetPositionKey(
string ObjectName,
int KeyIndex,
float X,
float Y,
float Z,
PlayoutVectorComponents Components) : PlayoutMutation;
public sealed record PlayoutSetScale(
string ObjectName,
float X,
float Y,
float Z,
PlayoutVectorComponents Components) : PlayoutMutation;
public sealed record PlayoutSetCropKey(
string ObjectName,
int KeyIndex,
float Left,
float Top,
float Right,
float Bottom,
PlayoutCropEdges Edges) : PlayoutMutation;
public sealed record PlayoutSetCircleAngleKey(
string ObjectName,
int KeyIndex,
float Start,
float End,
PlayoutAngleComponents Components) : PlayoutMutation;
public sealed record PlayoutSetPathPoints(
string ObjectName,
IReadOnlyList<PlayoutPoint> Points) : PlayoutMutation;
public sealed record PlayoutSetPathShapePoints(
string ObjectName,
IReadOnlyList<PlayoutPoint> Points) : PlayoutMutation;
public sealed record PlayoutUseBackground(
bool IsEnabled,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
public sealed record PlayoutSetBackgroundTexture(
string AssetPath,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
public sealed record PlayoutSetBackgroundVideo(
string AssetPath,
int LoopCount,
bool LoopInfinite,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
/// <summary>
/// Identifies a scene and the neutral object changes needed to prepare it.
/// SceneFile is an input only and is never copied into user-facing status/error text.
@@ -75,7 +195,8 @@ public sealed record PlayoutCue(
string SceneFile,
string SceneName,
IReadOnlyList<PlayoutField>? Fields = null,
int FadeDuration = 0);
int FadeDuration = 0,
IReadOnlyList<PlayoutMutation>? Mutations = null);
public sealed record PlayoutStatus(
PlayoutMode Mode,
@@ -115,9 +236,9 @@ public sealed record PlayoutStatus(
};
/// <summary>
/// The late-bound adapter currently cannot observe the 282-method IKAEventHandler callback
/// interface without a separately validated interop strategy. Null therefore means that the
/// OnHello callback was not programmatically verified, not that Tornado rejected the request.
/// True after the dynamically generated, vendor-compatible IKAEventHandler receives OnHello;
/// false when a completed connection lifecycle explicitly proves it was not observed. Null
/// means that callback delivery is still pending or could not be established reliably.
/// </summary>
public bool? KtapHelloObserved { get; init; }
@@ -185,6 +306,14 @@ public interface IPlayoutEngine : IAsyncDisposable
PlayoutCue cue,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates and replays the scene already on air without loading a replacement scene.
/// Used by MainForm-compatible timer refresh through GetPlayingScene.
/// </summary>
Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default);
Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default);

View File

@@ -1,6 +1,7 @@
#nullable enable
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Playout;
@@ -14,6 +15,26 @@ public sealed record PlayoutBridgeCommandParseResult(
PlayoutBridgeCommand Request,
string Error);
public sealed record PlayoutBridgeWorkflowCommand(
string RequestId,
string Command,
IReadOnlyList<LegacyPlaylistEntry>? Playlist,
int SelectedIndexZeroBased);
public sealed record PlayoutBridgeWorkflowCommandParseResult(
bool IsValid,
PlayoutBridgeWorkflowCommand Request,
string Error);
public sealed record PlayoutBridgeTimeoutQuarantine(
string RequestId,
string Command);
public sealed record PlayoutBridgeTimeoutQuarantineParseResult(
bool IsValid,
PlayoutBridgeTimeoutQuarantine Request,
string Error);
/// <summary>
/// Strict, COM-neutral parser for the local WebView playout command boundary.
/// Presentation text is intentionally not accepted as scene mutation data.
@@ -22,6 +43,8 @@ public static class PlayoutBridgeProtocol
{
private const int MaximumRequestIdLength = 128;
private const int MaximumSceneCodeLength = 64;
private const int MaximumPlaylistLength = 1_000;
private const int MaximumLookupTextLength = 256;
public static bool IsTrustedSource(string? source, string expectedHost) =>
!string.IsNullOrWhiteSpace(source) &&
@@ -77,6 +100,111 @@ public static class PlayoutBridgeProtocol
return Valid(new PlayoutBridgeCommand(requestId, command, null));
}
/// <summary>
/// Parses the native workflow boundary used by the migrated MainForm state machine.
/// PREPARE supplies an immutable playlist snapshot; NEXT has no caller-selected cue so
/// native PageN/playlist rules remain authoritative. Lookup text can only reach
/// parameterized scene-data loaders and is never interpreted as a K3D control surface.
/// </summary>
public static PlayoutBridgeWorkflowCommandParseResult ParseWorkflowCommand(
JsonElement payload)
{
var empty = new PlayoutBridgeWorkflowCommand(
string.Empty,
string.Empty,
null,
-1);
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId", "command", "playlist", "selectedIndex"))
{
return InvalidWorkflow(empty, "The playout workflow request shape is invalid.");
}
if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId))
{
return InvalidWorkflow(empty, "The playout workflow request identifier is invalid.");
}
var command = GetString(payload, "command") ?? string.Empty;
if (command is not ("prepare" or "take-in" or "next" or "take-out"))
{
return InvalidWorkflow(
new PlayoutBridgeWorkflowCommand(requestId, string.Empty, null, -1),
"The playout workflow command is not supported.");
}
var hasPlaylist = payload.TryGetProperty("playlist", out var playlistElement);
var hasSelectedIndex = payload.TryGetProperty("selectedIndex", out var indexElement);
if (command == "prepare")
{
if (!hasPlaylist || !hasSelectedIndex ||
!TryParsePlaylist(playlistElement, out var playlist) ||
indexElement.ValueKind != JsonValueKind.Number ||
!indexElement.TryGetInt32(out var selectedIndex) ||
selectedIndex < 0 || selectedIndex >= playlist!.Count)
{
return InvalidWorkflow(
new PlayoutBridgeWorkflowCommand(requestId, command, null, -1),
"The PREPARE playlist selection is invalid.");
}
return ValidWorkflow(new PlayoutBridgeWorkflowCommand(
requestId,
command,
playlist,
selectedIndex));
}
if (hasPlaylist || hasSelectedIndex)
{
return InvalidWorkflow(
new PlayoutBridgeWorkflowCommand(requestId, command, null, -1),
"Only PREPARE can supply a playlist selection.");
}
return ValidWorkflow(new PlayoutBridgeWorkflowCommand(
requestId,
command,
null,
-1));
}
/// <summary>
/// Parses the browser watchdog notification. A valid notification permanently
/// quarantines the native playout runtime for the remainder of the app process;
/// it is deliberately not a playout command and carries no scene data.
/// </summary>
public static PlayoutBridgeTimeoutQuarantineParseResult ParseTimeoutQuarantine(
JsonElement payload)
{
var empty = new PlayoutBridgeTimeoutQuarantine(string.Empty, string.Empty);
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId", "command"))
{
return InvalidTimeoutQuarantine(
empty,
"The playout timeout quarantine request shape is invalid.");
}
if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId))
{
return InvalidTimeoutQuarantine(
empty,
"The playout timeout quarantine request identifier is invalid.");
}
var command = GetString(payload, "command") ?? string.Empty;
if (command is not ("prepare" or "take-in" or "next" or "take-out"))
{
return InvalidTimeoutQuarantine(
new PlayoutBridgeTimeoutQuarantine(requestId, string.Empty),
"The timed-out playout command is not supported.");
}
return ValidTimeoutQuarantine(
new PlayoutBridgeTimeoutQuarantine(requestId, command));
}
private static bool TryParseCue(JsonElement payload, out PlayoutCue? cue)
{
cue = null;
@@ -91,6 +219,107 @@ public static class PlayoutBridgeProtocol
return true;
}
private static bool TryParsePlaylist(
JsonElement payload,
out IReadOnlyList<LegacyPlaylistEntry>? playlist)
{
playlist = null;
if (payload.ValueKind != JsonValueKind.Array ||
payload.GetArrayLength() is < 1 or > MaximumPlaylistLength)
{
return false;
}
var entries = new List<LegacyPlaylistEntry>(payload.GetArrayLength());
var identifiers = new HashSet<string>(StringComparer.Ordinal);
foreach (var element in payload.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(element, "id", "code", "enabled", "fadeDuration", "selection") ||
!TryGetSafeToken(element, "id", MaximumRequestIdLength, out var id) ||
!TryGetSafeToken(element, "code", MaximumSceneCodeLength, out var code) ||
!identifiers.Add(id) ||
!element.TryGetProperty("enabled", out var enabledElement) ||
enabledElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return false;
}
int? fadeDuration = null;
if (element.TryGetProperty("fadeDuration", out var fadeElement))
{
if (fadeElement.ValueKind != JsonValueKind.Number ||
!fadeElement.TryGetInt32(out var parsedFade) ||
parsedFade is < 0 or > 60)
{
return false;
}
fadeDuration = parsedFade;
}
LegacySceneSelection? selection = null;
if (element.TryGetProperty("selection", out var selectionElement) &&
!TryParseSelection(selectionElement, out selection))
{
return false;
}
entries.Add(new LegacyPlaylistEntry(
id,
code,
enabledElement.GetBoolean(),
fadeDuration,
selection));
}
playlist = Array.AsReadOnly(entries.ToArray());
return true;
}
private static bool TryParseSelection(
JsonElement payload,
out LegacySceneSelection? selection)
{
selection = null;
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(
payload,
"groupCode",
"subject",
"graphicType",
"subtype",
"dataCode") ||
!TryGetLookupText(payload, "groupCode", out var groupCode) ||
!TryGetLookupText(payload, "subject", out var subject) ||
!TryGetLookupText(payload, "graphicType", out var graphicType) ||
!TryGetLookupText(payload, "subtype", out var subtype) ||
!TryGetLookupText(payload, "dataCode", out var dataCode))
{
return false;
}
selection = new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode);
return true;
}
private static bool TryGetLookupText(
JsonElement payload,
string propertyName,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return payload.TryGetProperty(propertyName, out var element) &&
element.ValueKind == JsonValueKind.String &&
value.Length <= MaximumLookupTextLength &&
!value.Any(char.IsControl);
}
private static bool TryGetSafeToken(
JsonElement payload,
string propertyName,
@@ -128,4 +357,18 @@ public static class PlayoutBridgeProtocol
private static PlayoutBridgeCommandParseResult Invalid(
PlayoutBridgeCommand request,
string error) => new(false, request, error);
private static PlayoutBridgeWorkflowCommandParseResult ValidWorkflow(
PlayoutBridgeWorkflowCommand request) => new(true, request, string.Empty);
private static PlayoutBridgeWorkflowCommandParseResult InvalidWorkflow(
PlayoutBridgeWorkflowCommand request,
string error) => new(false, request, error);
private static PlayoutBridgeTimeoutQuarantineParseResult ValidTimeoutQuarantine(
PlayoutBridgeTimeoutQuarantine request) => new(true, request, string.Empty);
private static PlayoutBridgeTimeoutQuarantineParseResult InvalidTimeoutQuarantine(
PlayoutBridgeTimeoutQuarantine request,
string error) => new(false, request, error);
}

View File

@@ -0,0 +1,331 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout;
/// <summary>
/// The only page sizes used by the legacy PageN/Nxt_PageN workflow.
/// </summary>
public enum ScenePageSize
{
Five = 5,
Six = 6,
Twelve = 12
}
public enum ScenePagingResultKind
{
Valid,
Invalid
}
/// <summary>
/// A validated paging plan. Page numbers are one-based; item offsets are zero-based.
/// Items beyond <see cref="ScenePaging.MaximumPageCount"/> pages are deliberately excluded.
/// </summary>
public sealed record ScenePagingPlan
{
internal ScenePagingPlan(
int itemCount,
ScenePageSize pageSize,
int totalPages,
int accessibleItemCount)
{
ItemCount = itemCount;
PageSize = pageSize;
TotalPages = totalPages;
AccessibleItemCount = accessibleItemCount;
}
public int ItemCount { get; }
public ScenePageSize PageSize { get; }
public int TotalPages { get; }
public int AccessibleItemCount { get; }
public int ExcludedItemCount => ItemCount - AccessibleItemCount;
}
public sealed record ScenePagingPlanResult(
ScenePagingResultKind Kind,
ScenePagingPlan? Plan,
string Error)
{
public bool IsValid => Kind == ScenePagingResultKind.Valid;
}
/// <summary>
/// Describes one valid page. PageNumberOneBased is one-based; OffsetZeroBased is zero-based.
/// RemainingItemCount is limited to the accessible 20-page range.
/// </summary>
public sealed record ScenePageWindow(
int PageNumberOneBased,
int TotalPages,
int OffsetZeroBased,
int ItemCount,
int RemainingItemCount);
public sealed record ScenePageWindowResult(
ScenePagingResultKind Kind,
ScenePagingPlan? Plan,
ScenePageWindow? Window,
string Error)
{
public bool IsValid => Kind == ScenePagingResultKind.Valid;
}
/// <summary>
/// A playlist definition. ItemCount must be zero for a non-paged cue.
/// </summary>
public sealed record ScenePlaylistCue(
string CueCode,
bool IsEnabled,
ScenePageSize? PageSize = null,
int ItemCount = 0);
/// <summary>
/// Runtime state used to decide NEXT. CurrentCueIndexZeroBased is zero-based and
/// CurrentPageNumberOneBased is one-based. A non-paged cue, or a paged cue with no
/// available items, uses a null current page.
/// </summary>
public sealed record SceneNextState(
IReadOnlyList<ScenePlaylistCue>? Playlist,
int CurrentCueIndexZeroBased,
int? CurrentPageNumberOneBased);
public enum SceneNextDecisionKind
{
SameCueNextPage,
NextEnabledCue,
EndOfPlaylist,
Invalid
}
public sealed record SceneNextDecision(
SceneNextDecisionKind Kind,
int? TargetCueIndexZeroBased,
int? TargetPageNumberOneBased,
ScenePageWindow? TargetPage,
string Error)
{
public bool IsValid => Kind != SceneNextDecisionKind.Invalid;
}
/// <summary>
/// Pure PageN/Nxt_PageN calculations with no database, COM, or UI dependency.
/// </summary>
public static class ScenePaging
{
public const int MaximumPageCount = 20;
public static ScenePagingPlanResult CreatePlan(
int itemCount,
ScenePageSize pageSize)
{
if (itemCount < 0)
{
return InvalidPlan("ItemCount cannot be negative.");
}
if (!IsSupported(pageSize))
{
return InvalidPlan("PageSize must be 5, 6, or 12.");
}
var size = (int)pageSize;
var uncappedTotalPages = itemCount == 0
? 0
: ((itemCount - 1) / size) + 1;
var totalPages = Math.Min(MaximumPageCount, uncappedTotalPages);
var accessibleItemCount = Math.Min(itemCount, MaximumPageCount * size);
return new ScenePagingPlanResult(
ScenePagingResultKind.Valid,
new ScenePagingPlan(itemCount, pageSize, totalPages, accessibleItemCount),
string.Empty);
}
public static ScenePageWindowResult GetWindow(
int itemCount,
ScenePageSize pageSize,
int pageNumberOneBased)
{
var planResult = CreatePlan(itemCount, pageSize);
if (!planResult.IsValid || planResult.Plan is null)
{
return new ScenePageWindowResult(
ScenePagingResultKind.Invalid,
null,
null,
planResult.Error);
}
var plan = planResult.Plan;
if (pageNumberOneBased < 1 || pageNumberOneBased > plan.TotalPages)
{
return new ScenePageWindowResult(
ScenePagingResultKind.Invalid,
plan,
null,
"PageNumberOneBased is outside the available page range.");
}
var size = (int)plan.PageSize;
var offset = (pageNumberOneBased - 1) * size;
var count = Math.Min(size, plan.AccessibleItemCount - offset);
var remaining = plan.AccessibleItemCount - offset - count;
var window = new ScenePageWindow(
pageNumberOneBased,
plan.TotalPages,
offset,
count,
remaining);
return new ScenePageWindowResult(
ScenePagingResultKind.Valid,
plan,
window,
string.Empty);
}
public static SceneNextDecision DecideNext(SceneNextState? state)
{
if (state?.Playlist is null)
{
return InvalidDecision("Playlist state is required.");
}
var playlist = state.Playlist;
if (state.CurrentCueIndexZeroBased < 0 ||
state.CurrentCueIndexZeroBased >= playlist.Count)
{
return InvalidDecision("CurrentCueIndexZeroBased is outside the playlist.");
}
var plans = new ScenePagingPlan?[playlist.Count];
for (var index = 0; index < playlist.Count; index++)
{
var cue = playlist[index];
if (cue is null || string.IsNullOrWhiteSpace(cue.CueCode))
{
return InvalidDecision("Every playlist cue requires a cue code.");
}
if (cue.PageSize is null)
{
if (cue.ItemCount != 0)
{
return InvalidDecision("A non-paged cue must have ItemCount zero.");
}
continue;
}
var planResult = CreatePlan(cue.ItemCount, cue.PageSize.Value);
if (!planResult.IsValid || planResult.Plan is null)
{
return InvalidDecision(planResult.Error);
}
plans[index] = planResult.Plan;
}
var currentIndex = state.CurrentCueIndexZeroBased;
var currentCue = playlist[currentIndex];
var currentPlan = plans[currentIndex];
if (currentPlan is null)
{
if (state.CurrentPageNumberOneBased is not null)
{
return InvalidDecision("A non-paged cue cannot have a current page.");
}
}
else if (currentPlan.TotalPages == 0)
{
if (state.CurrentPageNumberOneBased is not null)
{
return InvalidDecision("A paged cue with no items cannot have a current page.");
}
}
else
{
var currentPage = state.CurrentPageNumberOneBased;
if (currentPage is null ||
currentPage.Value < 1 ||
currentPage.Value > currentPlan.TotalPages)
{
return InvalidDecision("CurrentPageNumberOneBased is outside the current cue.");
}
if (currentPage.Value < currentPlan.TotalPages)
{
var nextPage = currentPage.Value + 1;
var pageResult = GetWindow(
currentCue.ItemCount,
currentCue.PageSize!.Value,
nextPage);
if (!pageResult.IsValid || pageResult.Window is null)
{
return InvalidDecision(pageResult.Error);
}
return new SceneNextDecision(
SceneNextDecisionKind.SameCueNextPage,
currentIndex,
nextPage,
pageResult.Window,
string.Empty);
}
}
for (var index = currentIndex + 1; index < playlist.Count; index++)
{
if (!playlist[index].IsEnabled)
{
continue;
}
var targetPlan = plans[index];
int? targetPageNumber = targetPlan?.TotalPages > 0 ? 1 : null;
ScenePageWindow? targetPage = null;
if (targetPageNumber is not null)
{
var pageResult = GetWindow(
playlist[index].ItemCount,
playlist[index].PageSize!.Value,
targetPageNumber.Value);
if (!pageResult.IsValid || pageResult.Window is null)
{
return InvalidDecision(pageResult.Error);
}
targetPage = pageResult.Window;
}
return new SceneNextDecision(
SceneNextDecisionKind.NextEnabledCue,
index,
targetPageNumber,
targetPage,
string.Empty);
}
return new SceneNextDecision(
SceneNextDecisionKind.EndOfPlaylist,
null,
null,
null,
string.Empty);
}
private static bool IsSupported(ScenePageSize pageSize) =>
pageSize is ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve;
private static ScenePagingPlanResult InvalidPlan(string error) =>
new(ScenePagingResultKind.Invalid, null, error);
private static SceneNextDecision InvalidDecision(string error) =>
new(SceneNextDecisionKind.Invalid, null, null, null, error);
}

View File

@@ -0,0 +1,703 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum CandleCutAlias
{
Cut8035 = 8035,
Cut8061 = 8061,
Cut8040 = 8040,
Cut8046 = 8046,
Cut8051 = 8051,
Cut8056 = 8056
}
public enum CandlePeriod
{
Intraday,
FiveDays,
TwentyDays,
SixtyDays,
OneHundredTwentyDays,
TwoHundredFortyDays
}
public enum CandleChartMode
{
Price,
PriceWithVolume,
ExpectedExecution,
TradingHalt
}
public enum CandleMarketTarget
{
KospiIndex,
KosdaqIndex,
Kospi200Index,
Krx100Index,
FuturesIndex,
KospiIndustry,
KosdaqIndustry,
OverseasIndex,
OverseasStock,
KospiStock,
KosdaqStock
}
public static class CandleCutAliasMetadata
{
public static CandlePeriod GetPeriod(CandleCutAlias alias) => alias switch
{
CandleCutAlias.Cut8035 => CandlePeriod.Intraday,
CandleCutAlias.Cut8061 => CandlePeriod.FiveDays,
CandleCutAlias.Cut8040 => CandlePeriod.TwentyDays,
CandleCutAlias.Cut8046 => CandlePeriod.SixtyDays,
CandleCutAlias.Cut8051 => CandlePeriod.OneHundredTwentyDays,
CandleCutAlias.Cut8056 => CandlePeriod.TwoHundredFortyDays,
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
};
internal static double GetMovingAverageWidth(CandleCutAlias alias) => alias switch
{
CandleCutAlias.Cut8035 => 0d,
CandleCutAlias.Cut8061 => 1188d,
CandleCutAlias.Cut8040 => 1398d,
CandleCutAlias.Cut8046 => 1442d,
CandleCutAlias.Cut8051 => 1452d,
CandleCutAlias.Cut8056 => 1464d,
_ => throw new LegacySceneDataException("Unknown candle cut alias.")
};
}
public sealed record CandleQuoteData(
double CurrentPrice,
double ChangePrice,
double Rate,
ScenePriceDirection Direction);
public sealed record CandleRowData(
DateOnly TradingDate,
TimeOnly? TradingTime,
double Open,
double High,
double Low,
double Close,
double? Volume = null,
double? MovingAverage5 = null,
double? MovingAverage20 = null);
public sealed record S8010SceneData(
CandleCutAlias CutAlias,
CandleMarketTarget MarketTarget,
CandleChartMode ChartMode,
string DisplayName,
CandleQuoteData Quote,
IReadOnlyList<CandleRowData> Rows,
bool ShowMovingAverage5,
bool ShowMovingAverage20) : ILegacySceneData;
public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder<S8010SceneData>
{
private const int MaximumCandleCount = 240;
private const double GraphStartX = -730.57d;
private const double GraphEndX = 738.65d;
private const double TagMinimumX = -657.57d;
private const double TagMaximumX = 670.65d;
public string BuilderKey => "s8010";
public IReadOnlyList<PlayoutMutation> Build(S8010SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
ValidateEnums(data);
var displayName = LegacySceneBuilderGuard.Text(
data.DisplayName,
nameof(data.DisplayName));
var quote = LegacySceneBuilderGuard.NotNull(data.Quote, nameof(data.Quote));
ValidateQuote(quote);
var rows = NormalizeRows(data.Rows, data.CutAlias, data.ChartMode);
var period = CandleCutAliasMetadata.GetPeriod(data.CutAlias);
var lineWidth = CandleCutAliasMetadata.GetMovingAverageWidth(data.CutAlias);
var format = UsesIntegerPrices(data.MarketTarget) ? "#,##0" : "#,##0.00";
var mutations = new List<PlayoutMutation>(540 + (rows.Count * 12))
{
new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)),
// A path mutation with no points reproduces Begin/Clear/End from the legacy setup.
new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()),
new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>())
};
AddResolvedTitleAndUnit(
mutations,
displayName,
data.MarketTarget,
data.ChartMode);
AddQuote(mutations, quote, format);
var range = CreateRange(rows, data);
AddInitialVisibility(mutations, data.ChartMode);
var paths = AddCandles(
mutations,
rows,
data,
period,
lineWidth,
format,
range);
AddMovingAveragePaths(mutations, paths, data);
return mutations;
}
private static void ValidateEnums(S8010SceneData data)
{
if (!Enum.IsDefined(data.CutAlias) || !Enum.IsDefined(data.MarketTarget) ||
!Enum.IsDefined(data.ChartMode))
{
throw new LegacySceneDataException("Unknown candle scene selection.");
}
if (data.ChartMode == CandleChartMode.TradingHalt &&
data.MarketTarget is not (CandleMarketTarget.KospiStock or
CandleMarketTarget.KosdaqStock))
{
throw new LegacySceneDataException(
"Trading-halt candles require a domestic stock target.");
}
}
private static void ValidateQuote(CandleQuoteData quote)
{
if (!double.IsFinite(quote.CurrentPrice) ||
!double.IsFinite(quote.ChangePrice) ||
!double.IsFinite(quote.Rate) ||
!Enum.IsDefined(quote.Direction))
{
throw new LegacySceneDataException("Candle quote data is invalid.");
}
}
private static IReadOnlyList<CandleRowData> NormalizeRows(
IReadOnlyList<CandleRowData>? rows,
CandleCutAlias alias,
CandleChartMode chartMode)
{
var values = LegacySceneBuilderGuard.Range(
rows,
1,
MaximumCandleCount,
nameof(rows));
var requiresTime = CandleCutAliasMetadata.GetPeriod(alias) == CandlePeriod.Intraday;
return values.Select((row, index) =>
{
var value = LegacySceneBuilderGuard.NotNull(row, $"Rows[{index}]");
if (requiresTime && value.TradingTime is null)
{
throw new LegacySceneDataException(
$"Scene data field 'Rows[{index}].TradingTime' is required for 8035.");
}
if (!double.IsFinite(value.Open) || !double.IsFinite(value.High) ||
!double.IsFinite(value.Low) || !double.IsFinite(value.Close) ||
value.High < value.Low || value.High < value.Open ||
value.High < value.Close || value.Low > value.Open ||
value.Low > value.Close)
{
throw new LegacySceneDataException(
$"Scene data field 'Rows[{index}]' has an invalid OHLC range.");
}
ValidateOptionalFinite(value.Volume, $"Rows[{index}].Volume");
ValidateOptionalFinite(value.MovingAverage5, $"Rows[{index}].MovingAverage5");
ValidateOptionalFinite(value.MovingAverage20, $"Rows[{index}].MovingAverage20");
if (value.Volume < 0)
{
throw new LegacySceneDataException(
$"Scene data field 'Rows[{index}].Volume' cannot be negative.");
}
if (chartMode == CandleChartMode.PriceWithVolume && value.Volume is null)
{
// The vendor source treats DB null as zero. Normalize it before calculating max.
return value with { Volume = 0d };
}
return value;
}).ToArray();
}
private static void ValidateOptionalFinite(double? value, string name)
{
if (value.HasValue && !double.IsFinite(value.Value))
{
throw new LegacySceneDataException($"Scene data field '{name}' must be finite.");
}
}
private static string InitialTitle(string displayName, CandleChartMode mode) =>
mode == CandleChartMode.ExpectedExecution
? displayName + " \uC608\uC0C1\uCCB4\uACB0"
: displayName;
private static void AddResolvedTitleAndUnit(
ICollection<PlayoutMutation> mutations,
string displayName,
CandleMarketTarget target,
CandleChartMode mode)
{
if (target == CandleMarketTarget.OverseasStock)
{
mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : \uB2EC\uB7EC"));
return;
}
if (target == CandleMarketTarget.OverseasIndex)
{
mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"));
return;
}
if (IsIndex(target))
{
var title = MarketLabel(target) + (mode == CandleChartMode.ExpectedExecution
? " \uC608\uC0C1\uCCB4\uACB0\uC9C0\uC218"
: " \uC9C0\uC218");
mutations.Add(new PlayoutSetValue("title", title));
mutations.Add(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"));
return;
}
if (mode == CandleChartMode.ExpectedExecution)
{
// The legacy non-index expected-price branch only rewrites the title.
mutations.Add(new PlayoutSetValue(
"title",
displayName + " \uC608\uC0C1\uCCB4\uACB0"));
return;
}
var resolvedTitle = target switch
{
CandleMarketTarget.KospiIndustry => "\uCF54\uC2A4\uD53C - " + displayName,
CandleMarketTarget.KosdaqIndustry => "\uCF54\uC2A4\uB2E5 - " + displayName,
_ => displayName
};
var unit = IsIndustry(target) ? "\uB2E8\uC704 : p" : "\uB2E8\uC704 : \uC6D0";
// The legacy stock/industry branch writes unit before its resolved title.
mutations.Add(new PlayoutSetValue("unit", unit));
mutations.Add(new PlayoutSetValue("title", resolvedTitle));
}
private static string MarketLabel(CandleMarketTarget target) => target switch
{
CandleMarketTarget.KospiIndex => "\uCF54\uC2A4\uD53C",
CandleMarketTarget.KosdaqIndex => "\uCF54\uC2A4\uB2E5",
CandleMarketTarget.Kospi200Index => "\uCF54\uC2A4\uD53C200",
CandleMarketTarget.Krx100Index => "KRX100",
CandleMarketTarget.FuturesIndex => "\uC120\uBB3C",
_ => throw new LegacySceneDataException("The target is not a domestic index.")
};
private static void AddQuote(
ICollection<PlayoutMutation> mutations,
CandleQuoteData quote,
string format)
{
mutations.Add(new PlayoutSetValue(
"price",
quote.CurrentPrice.ToString(format, CultureInfo.InvariantCulture)));
mutations.Add(new PlayoutSetValue(
"changePrice",
quote.ChangePrice.ToString(format, CultureInfo.InvariantCulture)));
mutations.Add(new PlayoutSetValue(
"rate",
quote.Rate.ToString("##0.00", CultureInfo.InvariantCulture)));
CandleQuoteDirection.Add(mutations, quote.Direction);
}
private static CandleRange CreateRange(
IReadOnlyList<CandleRowData> rows,
S8010SceneData data)
{
var candleMinimum = rows.Min(row => row.Low);
var candleMaximum = rows.Max(row => row.High);
var minimum = candleMinimum;
var maximum = candleMaximum;
if (SupportsMovingAverages(data.MarketTarget))
{
if (data.ShowMovingAverage5)
{
var values = rows.Where(row => row.MovingAverage5.HasValue)
.Select(row => row.MovingAverage5!.Value)
.ToArray();
if (values.Length != 0)
{
minimum = Math.Min(minimum, values.Min());
maximum = Math.Max(maximum, values.Max());
}
}
if (data.ShowMovingAverage20)
{
var values = rows.Where(row => row.MovingAverage20.HasValue)
.Select(row => row.MovingAverage20!.Value)
.ToArray();
if (values.Length != 0)
{
minimum = Math.Min(minimum, values.Min());
maximum = Math.Max(maximum, values.Max());
}
}
}
var gap = maximum - minimum;
if (!double.IsFinite(gap))
{
throw new LegacySceneDataException(
"Candle price range cannot be represented as a finite value.");
}
if (gap == 0d)
{
// Equal OHLC input formerly divided by zero. A unit range keeps every crop finite.
gap = 1d;
}
var maximumVolume = data.ChartMode == CandleChartMode.PriceWithVolume
? rows.Max(row => row.Volume ?? 0d)
: 0d;
return new CandleRange(
minimum,
maximum,
gap,
candleMinimum,
candleMaximum,
maximumVolume);
}
private static void AddInitialVisibility(
ICollection<PlayoutMutation> mutations,
CandleChartMode mode)
{
mutations.Add(new PlayoutSetVisible(
"volrumeG",
mode == CandleChartMode.PriceWithVolume));
for (var index = 1; index <= MaximumCandleCount; index++)
{
mutations.Add(new PlayoutSetVisible("volume" + index, false));
}
for (var index = 1; index <= MaximumCandleCount; index++)
{
mutations.Add(new PlayoutSetVisible("candle" + index, false));
}
}
private static CandlePaths AddCandles(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<CandleRowData> rows,
S8010SceneData data,
CandlePeriod period,
double lineWidth,
string format,
CandleRange range)
{
var ma5Points = new List<PlayoutPoint>(rows.Count);
var ma20Points = new List<PlayoutPoint>(rows.Count);
var needMovingAverageChecker = false;
var movingAverageCounter = 0;
var originalGraphLength = GraphEndX - GraphStartX;
var endX = GraphEndX;
var tagMaximum = TagMaximumX;
var length = originalGraphLength;
var lengthPerCandle = length / rows.Count;
if (period == CandlePeriod.Intraday)
{
endX = (originalGraphLength * rows.Count / 78d) + GraphStartX;
tagMaximum = endX - 68d;
length = endX - GraphStartX;
lengthPerCandle = length / rows.Count;
}
for (var zeroBased = 0; zeroBased < rows.Count; zeroBased++)
{
var row = rows[zeroBased];
var index = zeroBased + 1;
AddDateLabel(mutations, rows, period, zeroBased);
if (row.High.Equals(range.CandleMaximum))
{
var x = TagPosition(index, lengthPerCandle, tagMaximum);
mutations.Add(new PlayoutSetPosition(
"highTag", (float)x, 0, 0, PlayoutVectorComponents.X));
mutations.Add(new PlayoutSetValue(
"highPrice", row.High.ToString(format, CultureInfo.InvariantCulture)));
}
if (row.Low.Equals(range.CandleMinimum))
{
var x = TagPosition(index, lengthPerCandle, tagMaximum);
mutations.Add(new PlayoutSetPosition(
"lowTag", (float)x, 0, 0, PlayoutVectorComponents.X));
mutations.Add(new PlayoutSetValue(
"lowPrice", row.Low.ToString(format, CultureInfo.InvariantCulture)));
}
mutations.Add(new PlayoutSetVisible("candle" + index, true));
mutations.Add(Crop(
"candlebar" + index,
Normalize(row.High, range),
Normalize(row.Low, range)));
if (row.MovingAverage5.HasValue)
{
var include = true;
if (needMovingAverageChecker)
{
movingAverageCounter++;
include = movingAverageCounter >= 4;
}
if (include)
{
ma5Points.Add(MovingAveragePoint(
row.MovingAverage5.Value,
zeroBased,
rows.Count,
lineWidth,
range));
}
}
else
{
needMovingAverageChecker = true;
}
if (row.MovingAverage20.HasValue &&
(!needMovingAverageChecker || movingAverageCounter >= 19))
{
ma20Points.Add(MovingAveragePoint(
row.MovingAverage20.Value,
zeroBased,
rows.Count,
lineWidth,
range));
}
AddCandleBody(mutations, row, index, range);
if (data.ChartMode == CandleChartMode.PriceWithVolume)
{
var volume = row.Volume ?? 0d;
var percentage = range.MaximumVolume == 0d
? 0d
: Math.Abs(volume / range.MaximumVolume * 100d);
mutations.Add(new PlayoutSetVisible("volume" + index, true));
mutations.Add(Crop("volume" + index, 100d - percentage, 100d));
}
}
return new CandlePaths(ma5Points, ma20Points);
}
private static void AddDateLabel(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<CandleRowData> rows,
CandlePeriod period,
int zeroBasedIndex)
{
if (period == CandlePeriod.Intraday)
{
if (zeroBasedIndex != 0)
{
return;
}
var startsAtNine = rows[0].TradingTime!.Value.Hour == 9;
mutations.Add(new PlayoutSetValue("date_begin", startsAtNine ? "09:00" : "10:00"));
mutations.Add(new PlayoutSetValue("date_middle", startsAtNine ? "12:00" : "13:00"));
mutations.Add(new PlayoutSetValue("date_end", startsAtNine ? "15:30" : "16:30"));
return;
}
var index = zeroBasedIndex + 1;
var value = rows[zeroBasedIndex].TradingDate.ToString("MM/dd", CultureInfo.InvariantCulture);
if (index == 1)
{
mutations.Add(new PlayoutSetValue("date_begin", value));
}
else if (index == rows.Count)
{
mutations.Add(new PlayoutSetValue("date_end", value));
}
else if (index == (int)Math.Ceiling(rows.Count / 2d))
{
mutations.Add(new PlayoutSetValue("date_middle", value));
}
}
private static double TagPosition(
int oneBasedIndex,
double lengthPerCandle,
double tagMaximum)
{
var x = GraphStartX + (oneBasedIndex * lengthPerCandle) - (lengthPerCandle / 2d);
if (x > tagMaximum)
{
x = tagMaximum;
}
else if (x < TagMinimumX)
{
x = TagMinimumX;
}
return x;
}
private static double Normalize(double value, CandleRange range) =>
Math.Abs((value - range.Maximum) / range.Gap * 100d);
private static PlayoutSetCropKey Crop(string objectName, double top, double bottom) =>
new(
objectName,
1,
0,
(float)top,
0,
(float)bottom,
PlayoutCropEdges.Vertical);
private static PlayoutPoint MovingAveragePoint(
double value,
int zeroBasedIndex,
int count,
double lineWidth,
CandleRange range)
{
var x = count <= 1
? 0f
: (float)(lineWidth / (count - 1d)) * zeroBasedIndex;
var y = (float)((value - range.Maximum) / range.Gap * 196d) + 98f;
return new PlayoutPoint(x, y, 0);
}
private static void AddCandleBody(
ICollection<PlayoutMutation> mutations,
CandleRowData row,
int index,
CandleRange range)
{
var open = Normalize(row.Open, range);
var close = Normalize(row.Close, range);
var bodyName = "candlebody" + index;
var barName = "candlebar" + index;
if (row.Open > row.Close)
{
mutations.Add(Crop(bodyName, open, close));
mutations.Add(new PlayoutSetFaceColor(bodyName, 15, 99, 189));
mutations.Add(new PlayoutSetFaceColor(barName, 15, 99, 189));
}
else if (row.Open < row.Close)
{
mutations.Add(Crop(bodyName, close, open));
mutations.Add(new PlayoutSetFaceColor(bodyName, 170, 0, 0));
mutations.Add(new PlayoutSetFaceColor(barName, 170, 0, 0));
}
else
{
mutations.Add(Crop(bodyName, close, open + 1d));
mutations.Add(new PlayoutSetFaceColor(bodyName, 95, 95, 95));
mutations.Add(new PlayoutSetFaceColor(barName, 95, 95, 95));
}
}
private static void AddMovingAveragePaths(
ICollection<PlayoutMutation> mutations,
CandlePaths paths,
S8010SceneData data)
{
mutations.Add(new PlayoutSetPathPoints(
"ma5",
data.ShowMovingAverage5 ? paths.MovingAverage5 : Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetPathPoints(
"ma20",
data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20));
if (!SupportsMovingAverages(data.MarketTarget))
{
mutations.Add(new PlayoutSetVisible("guide", false));
mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty<PlayoutPoint>()));
mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty<PlayoutPoint>()));
}
}
private static bool UsesIntegerPrices(CandleMarketTarget target) => target is
CandleMarketTarget.KospiStock or CandleMarketTarget.KosdaqStock;
private static bool IsIndex(CandleMarketTarget target) => target is
CandleMarketTarget.KospiIndex or CandleMarketTarget.KosdaqIndex or
CandleMarketTarget.Kospi200Index or CandleMarketTarget.Krx100Index or
CandleMarketTarget.FuturesIndex;
private static bool IsIndustry(CandleMarketTarget target) => target is
CandleMarketTarget.KospiIndustry or CandleMarketTarget.KosdaqIndustry;
private static bool SupportsMovingAverages(CandleMarketTarget target) =>
!IsIndex(target) && !IsIndustry(target) && target != CandleMarketTarget.OverseasIndex;
private sealed record CandleRange(
double Minimum,
double Maximum,
double Gap,
double CandleMinimum,
double CandleMaximum,
double MaximumVolume);
private sealed record CandlePaths(
IReadOnlyList<PlayoutPoint> MovingAverage5,
IReadOnlyList<PlayoutPoint> MovingAverage20);
}
internal static class CandleQuoteDirection
{
private static readonly string[] DirectionObjects =
["upup", "up", "flat", "down", "downdown"];
public static void Add(
ICollection<PlayoutMutation> mutations,
ScenePriceDirection direction)
{
foreach (var name in DirectionObjects)
{
mutations.Add(new PlayoutSetVisible(name, false));
}
string? active = direction switch
{
ScenePriceDirection.LimitUp => "upup",
ScenePriceDirection.Up => "up",
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => null,
ScenePriceDirection.Down => "down",
ScenePriceDirection.LimitDown => "downdown",
_ => throw new LegacySceneDataException("Unknown candle quote direction.")
};
if (active is not null)
{
mutations.Add(new PlayoutSetVisible(active, true));
}
var selected = direction switch
{
ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1,
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2,
ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3,
_ => throw new LegacySceneDataException("Unknown candle quote direction.")
};
for (var index = 1; index <= 3; index++)
{
mutations.Add(new PlayoutSetVisible("bg" + index, index == selected));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,274 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Closed result for the five chart cuts whose legacy constructors selected a DB
/// branch from MainForm's code/jongmok/forCutInfo/sub playlist columns.
/// </summary>
public abstract record LegacyChartSceneLoadRequest(string BuilderKey);
public sealed record LegacyS5078ChartLoadRequest(S5078SceneDataRequest Request)
: LegacyChartSceneLoadRequest("s5078");
public sealed record LegacyS5079ChartLoadRequest(S5079SceneDataRequest Request)
: LegacyChartSceneLoadRequest("s5079");
public sealed record LegacyS5080ChartLoadRequest(S5080SceneDataRequest Request)
: LegacyChartSceneLoadRequest("s5080");
public sealed record LegacyS5083ChartLoadRequest(S5083SceneDataRequest Request)
: LegacyChartSceneLoadRequest("s5083");
public sealed record LegacyS5084ChartLoadRequest(S5084SceneDataRequest Request)
: LegacyChartSceneLoadRequest("s5084");
/// <summary>
/// Reproduces MainForm's five chart-constructor inputs without carrying forward its
/// substring-based cut dispatch. LegacySceneSelection maps GroupCode=code,
/// Subject=jongmok, GraphicType=forCutInfo, Subtype=sub and DataCode=the hidden
/// stock-code column. Every accepted selector belongs to an explicit closed set.
/// </summary>
public sealed class ChartLegacySceneRequestResolver
{
private const int MaximumFieldLength = 256;
private const int MaximumDataCodeLength = 32;
public LegacyChartSceneLoadRequest Resolve(LegacyPlaylistEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return entry.CutCode switch
{
"5078" => new LegacyS5078ChartLoadRequest(CreateS5078Request(entry)),
"5079" => new LegacyS5079ChartLoadRequest(CreateS5079Request(entry)),
"5080" => new LegacyS5080ChartLoadRequest(CreateS5080Request(entry)),
"5083" => new LegacyS5083ChartLoadRequest(CreateS5083Request(entry)),
"5084" => new LegacyS5084ChartLoadRequest(CreateS5084Request(entry)),
_ => throw InvalidSelection()
};
}
public S5078SceneDataRequest CreateS5078Request(LegacyPlaylistEntry entry)
{
var selection = Require(entry, "5078");
RequireEmpty(selection.Subtype);
RequireEmpty(selection.DataCode);
var subject = RequiredExact(selection.Subject);
var group = OptionalTrimmed(selection.GroupCode);
var graphic = RequiredTrimmed(selection.GraphicType);
var market = subject switch
{
"다우" or "DOW" => SectorIndexMarket.Dow,
"나스닥" or "NASDAQ" => SectorIndexMarket.Nasdaq,
"S&P" or "S&P500" => SectorIndexMarket.StandardAndPoor,
"코스피" or "KOSPI" => SectorIndexMarket.Kospi,
"코스닥" or "KOSDAQ" => SectorIndexMarket.Kosdaq,
_ => throw InvalidSelection()
};
if (market is SectorIndexMarket.Dow or
SectorIndexMarket.Nasdaq or
SectorIndexMarket.StandardAndPoor)
{
if (!IsOneOf(group, "해외지수", "FOREIGN_INDEX") ||
!IsOneOf(graphic, "미국업종", "US_SECTOR_INDEX"))
{
throw InvalidSelection();
}
}
else if (market == SectorIndexMarket.Kospi)
{
if (!IsOneOf(group, "업종_코스피", "INDUSTRY_KOSPI") ||
!IsOneOf(graphic, "섹터지수", "SECTOR_INDEX"))
{
throw InvalidSelection();
}
}
else if (!IsOneOf(group, "업종_코스닥", "INDUSTRY_KOSDAQ") ||
!IsOneOf(graphic, "섹터지수", "SECTOR_INDEX"))
{
throw InvalidSelection();
}
return new S5078SceneDataRequest(market);
}
public S5079SceneDataRequest CreateS5079Request(LegacyPlaylistEntry entry)
{
var selection = Require(entry, "5079");
ValidateManualStockSelection(selection, "성장성 지표", "GROWTH_METRICS");
return new S5079SceneDataRequest(RequiredExact(selection.Subject));
}
public S5080SceneDataRequest CreateS5080Request(LegacyPlaylistEntry entry)
{
var selection = Require(entry, "5080");
ValidateManualStockSelection(selection, "매출액", "SALES");
return new S5080SceneDataRequest(RequiredExact(selection.Subject));
}
public S5083SceneDataRequest CreateS5083Request(LegacyPlaylistEntry entry)
{
var selection = Require(entry, "5083");
RequireIndexLineGraph(selection, expectedGroup: "전체", canonicalGroup: "ALL");
var participant = RequiredTrimmed(selection.GraphicType) switch
{
"개인 매매동향" or "INDIVIDUAL_TRADING_TREND" =>
InvestorFlowParticipant.Individual,
"기관 매매동향" or "INSTITUTION_TRADING_TREND" =>
InvestorFlowParticipant.Institution,
"외국인 매매동향" or "FOREIGN_TRADING_TREND" =>
InvestorFlowParticipant.Foreign,
_ => throw InvalidSelection()
};
return new S5083SceneDataRequest(participant);
}
public S5084SceneDataRequest CreateS5084Request(LegacyPlaylistEntry entry)
{
var selection = Require(entry, "5084");
RequireCommonIndexLineFields(selection);
var group = RequiredTrimmed(selection.GroupCode);
var index = RequiredTrimmed(selection.GraphicType) switch
{
"코스피 매매동향" or "KOSPI_TRADING_TREND"
when IsOneOf(group, "코스피", "KOSPI") => InvestorFlowIndex.Kospi,
"코스닥 매매동향" or "KOSDAQ_TRADING_TREND"
when IsOneOf(group, "코스닥", "KOSDAQ") => InvestorFlowIndex.Kosdaq,
_ => throw InvalidSelection()
};
return new S5084SceneDataRequest(index);
}
private static void ValidateManualStockSelection(
LegacySceneSelection selection,
string originalGraphic,
string canonicalGraphic)
{
_ = RequiredExact(selection.Subject);
var group = RequiredTrimmed(selection.GroupCode);
if (!IsOneOf(
group,
"코스피",
"코스닥",
"코스피_NXT",
"코스닥_NXT",
"KOSPI",
"KOSDAQ",
"NXT_KOSPI",
"NXT_KOSDAQ"))
{
throw InvalidSelection();
}
if (!IsOneOf(RequiredTrimmed(selection.GraphicType), originalGraphic, canonicalGraphic))
{
throw InvalidSelection();
}
RequireEmpty(selection.Subtype);
ValidateOptionalDataCode(selection.DataCode);
}
private static void RequireIndexLineGraph(
LegacySceneSelection selection,
string expectedGroup,
string canonicalGroup)
{
RequireCommonIndexLineFields(selection);
if (!IsOneOf(RequiredTrimmed(selection.GroupCode), expectedGroup, canonicalGroup))
{
throw InvalidSelection();
}
}
private static void RequireCommonIndexLineFields(LegacySceneSelection selection)
{
if (!IsOneOf(RequiredTrimmed(selection.Subject), "지수", "INDEX") ||
!IsOneOf(RequiredTrimmed(selection.Subtype), "라인그래프", "LINE_GRAPH"))
{
throw InvalidSelection();
}
RequireEmpty(selection.DataCode);
}
private static LegacySceneSelection Require(LegacyPlaylistEntry entry, string cutCode)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.CutCode != cutCode || entry.Selection is null)
{
throw InvalidSelection();
}
ValidateField(entry.Selection.GroupCode);
ValidateField(entry.Selection.Subject);
ValidateField(entry.Selection.GraphicType);
ValidateField(entry.Selection.Subtype);
ValidateField(entry.Selection.DataCode);
return entry.Selection;
}
private static string RequiredExact(string? value)
{
ValidateField(value);
if (string.IsNullOrWhiteSpace(value) || value != value.Trim())
{
throw InvalidSelection();
}
return value;
}
private static string RequiredTrimmed(string? value)
{
ValidateField(value);
var trimmed = value!.Trim();
return trimmed.Length != 0 ? trimmed : throw InvalidSelection();
}
private static string OptionalTrimmed(string? value)
{
ValidateField(value);
return value!.Trim();
}
private static void RequireEmpty(string? value)
{
ValidateField(value);
if (!string.IsNullOrWhiteSpace(value))
{
throw InvalidSelection();
}
}
private static void ValidateOptionalDataCode(string? value)
{
ValidateField(value);
if (string.IsNullOrEmpty(value))
{
return;
}
if (value.Length > MaximumDataCodeLength ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw InvalidSelection();
}
}
private static void ValidateField(string? value)
{
if (value is null || value.Length > MaximumFieldLength || value.Any(char.IsControl))
{
throw InvalidSelection();
}
}
private static bool IsOneOf(string value, params string[] accepted) =>
accepted.Contains(value, StringComparer.Ordinal);
private static LegacySceneDataException InvalidSelection() =>
new("The chart legacy selection is unsupported or invalid.");
}

View File

@@ -0,0 +1,735 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum SectorIndexMarket
{
Dow,
Nasdaq,
StandardAndPoor,
Kospi,
Kosdaq
}
public enum SectorBarVariant
{
Primary,
Secondary
}
public sealed record SectorIndexBarData(
string Category,
double Value,
SectorBarVariant Variant);
public sealed record S5078SceneData(
SectorIndexMarket Market,
IReadOnlyList<SectorIndexBarData> Rows) : ILegacySceneData;
public sealed class S5078SceneMutationBuilder : ILegacySceneMutationBuilder<S5078SceneData>
{
public string BuilderKey => "s5078";
public IReadOnlyList<PlayoutMutation> Build(S5078SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var rows = LegacySceneBuilderGuard.Count(data.Rows, 10, nameof(data.Rows));
var (title, primaryScaleFactor) = data.Market switch
{
SectorIndexMarket.Dow => ("다우 섹터지수", -1d),
SectorIndexMarket.Nasdaq => ("나스닥 섹터지수", -1d),
SectorIndexMarket.StandardAndPoor => ("S&P 섹터지수", -1d),
SectorIndexMarket.Kospi => ("코스피 섹터지수", 1d),
SectorIndexMarket.Kosdaq => ("코스닥 섹터지수", 1d),
_ => throw new LegacySceneDataException("Unknown sector index market.")
};
var validatedRows = new SectorIndexBarData[rows.Count];
var maximumMagnitude = 0d;
for (var index = 0; index < rows.Count; index++)
{
var row = LegacySceneBuilderGuard.NotNull(rows[index], $"Rows[{index}]");
LegacySceneBuilderGuard.Text(row.Category, $"Rows[{index}].Category");
if (!double.IsFinite(row.Value))
{
throw new LegacySceneDataException("Sector index values must be finite.");
}
_ = VariantSuffix(row.Variant);
validatedRows[index] = row;
maximumMagnitude = Math.Max(maximumMagnitude, Math.Abs(row.Value));
}
var mutations = new List<PlayoutMutation>(91)
{
new PlayoutSetValue("title", title)
};
for (var index = 1; index <= 10; index++)
{
mutations.Add(new PlayoutSetVisible($"bar{index}_1", false));
mutations.Add(new PlayoutSetVisible($"value{index}_1", false));
mutations.Add(new PlayoutSetVisible($"bar{index}_2", false));
mutations.Add(new PlayoutSetVisible($"value{index}_2", false));
}
for (var index = 1; index <= validatedRows.Length; index++)
{
var row = validatedRows[index - 1];
var suffix = VariantSuffix(row.Variant);
var scaleFactor = row.Variant == SectorBarVariant.Primary
? primaryScaleFactor
: 1d;
var scale = maximumMagnitude == 0d
? 0f
: LegacyChartSafety.FiniteFloat(
row.Value / maximumMagnitude * scaleFactor,
"Sector index scale");
mutations.Add(new PlayoutSetValue(
"category" + index,
row.Category));
mutations.Add(new PlayoutSetVisible($"bar{index}_{suffix}", true));
mutations.Add(new PlayoutSetVisible($"value{index}_{suffix}", true));
mutations.Add(new PlayoutSetScale(
$"bar{index}_{suffix}",
scale,
0,
0,
PlayoutVectorComponents.X));
mutations.Add(new PlayoutSetValue(
$"value{index}_{suffix}",
row.Value.ToString("##0.00", CultureInfo.InvariantCulture)));
}
return mutations;
}
private static int VariantSuffix(SectorBarVariant variant) => variant switch
{
SectorBarVariant.Primary => 1,
SectorBarVariant.Secondary => 2,
_ => throw new LegacySceneDataException("Unknown sector bar variant.")
};
}
public enum GrowthMetricPath
{
First,
Second,
Third,
Fourth
}
/// <summary>
/// Null values represent the legacy empty token and are normalized to zero. The first
/// value controls visibility exactly as the original rs[0] empty check did.
/// </summary>
public sealed record GrowthMetricSeriesData(
GrowthMetricPath Path,
IReadOnlyList<double?> Values);
public sealed record S5079SceneData(
string StockName,
IReadOnlyList<GrowthMetricSeriesData> Series,
IReadOnlyList<string> Dates) : ILegacySceneData;
public sealed class S5079SceneMutationBuilder : ILegacySceneMutationBuilder<S5079SceneData>
{
private static readonly float[] XPositions = [0, 368, 740, 1110];
public string BuilderKey => "s5079";
public IReadOnlyList<PlayoutMutation> Build(S5079SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var sourceSeries = LegacySceneBuilderGuard.Count(data.Series, 4, nameof(data.Series));
var dates = LegacySceneBuilderGuard.Count(data.Dates, 4, nameof(data.Dates));
var normalized = new Dictionary<GrowthMetricPath, NormalizedGrowthSeries>();
for (var seriesIndex = 0; seriesIndex < sourceSeries.Count; seriesIndex++)
{
var series = LegacySceneBuilderGuard.NotNull(
sourceSeries[seriesIndex],
$"Series[{seriesIndex}]");
_ = PathNumber(series.Path);
var values = LegacySceneBuilderGuard.Count(
series.Values,
4,
$"Series[{seriesIndex}].Values");
var normalizedValues = new double[4];
for (var valueIndex = 0; valueIndex < values.Count; valueIndex++)
{
var value = values[valueIndex] ?? 0d;
if (!double.IsFinite(value))
{
throw new LegacySceneDataException("Growth metric values must be finite.");
}
normalizedValues[valueIndex] = value;
}
if (!normalized.TryAdd(
series.Path,
new NormalizedGrowthSeries(values[0].HasValue, normalizedValues)))
{
throw new LegacySceneDataException("Growth metric paths must be unique.");
}
}
foreach (var path in Enum.GetValues<GrowthMetricPath>())
{
if (!normalized.ContainsKey(path))
{
throw new LegacySceneDataException("All four growth metric paths are required.");
}
}
var validatedDates = new string[dates.Count];
for (var index = 0; index < dates.Count; index++)
{
validatedDates[index] = LegacySceneBuilderGuard.Text(
dates[index],
$"Dates[{index}]",
allowEmpty: true);
}
var allValues = normalized.Values.SelectMany(item => item.Values).ToArray();
var maximum = allValues.Max();
var minimum = allValues.Min();
var range = maximum - minimum;
if (!double.IsFinite(range))
{
throw new LegacySceneDataException("Growth metric range must be finite.");
}
var gap = range / 5d;
var mutations = new List<PlayoutMutation>(36)
{
new PlayoutSetValue("title", stockName + " 성장성 지표")
};
for (var index = 1; index <= 5; index++)
{
mutations.Add(new PlayoutSetValue(
"value" + index,
Math.Ceiling(minimum + (gap * index))
.ToString(CultureInfo.InvariantCulture)));
}
foreach (var path in Enum.GetValues<GrowthMetricPath>())
{
var pathNumber = PathNumber(path);
var series = normalized[path];
var points = new PlayoutPoint[4];
for (var index = 0; index < points.Length; index++)
{
var y = range == 0d
? 0f
: LegacyChartSafety.FiniteFloat(
(((360d * (series.Values[index] - minimum) / range) - 180d) * 0.8d),
"Growth metric Y position");
points[index] = new PlayoutPoint(XPositions[index], y, 0);
}
AppendLegacyGrowthPath(
mutations,
pathNumber,
series.IsVisible,
points);
}
for (var index = 1; index <= validatedDates.Length; index++)
{
mutations.Add(new PlayoutSetValue("date" + index, validatedDates[index - 1]));
}
return mutations;
}
private static void AppendLegacyGrowthPath(
ICollection<PlayoutMutation> mutations,
int pathNumber,
bool isVisible,
IReadOnlyList<PlayoutPoint> points)
{
var objectName = "path" + pathNumber;
if (pathNumber != 3)
{
mutations.Add(new PlayoutSetVisible(objectName, false));
if (isVisible)
{
// The original sets visibility once per point inside its AddPathPoint loop.
for (var index = 0; index < points.Count; index++)
{
mutations.Add(new PlayoutSetVisible(objectName, true));
}
}
}
// PlayoutSetPathPoints expands to Begin/Clear/Add*/End in the adapter.
mutations.Add(new PlayoutSetPathPoints(objectName, points));
if (pathNumber == 3)
{
mutations.Add(new PlayoutSetVisible(objectName, isVisible));
}
}
private static int PathNumber(GrowthMetricPath path) => path switch
{
GrowthMetricPath.First => 1,
GrowthMetricPath.Second => 2,
GrowthMetricPath.Third => 3,
GrowthMetricPath.Fourth => 4,
_ => throw new LegacySceneDataException("Unknown growth metric path.")
};
private sealed record NormalizedGrowthSeries(bool IsVisible, double[] Values);
}
public sealed record QuarterlySalesData(string Quarter, int Value);
public sealed record S5080SceneData(
string StockName,
IReadOnlyList<QuarterlySalesData> Quarters) : ILegacySceneData;
public sealed class S5080SceneMutationBuilder : ILegacySceneMutationBuilder<S5080SceneData>
{
public string BuilderKey => "s5080";
public IReadOnlyList<PlayoutMutation> Build(S5080SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters));
var validated = new QuarterlySalesData[quarters.Count];
var mutations = new List<PlayoutMutation>(38)
{
new PlayoutSetValue("title", stockName + " 매출액")
};
for (var index = 0; index < quarters.Count; index++)
{
var quarter = LegacySceneBuilderGuard.NotNull(
quarters[index],
$"Quarters[{index}]");
validated[index] = quarter;
mutations.Add(new PlayoutSetValue(
"quarter" + (index + 1),
LegacySceneBuilderGuard.Text(
quarter.Quarter,
$"Quarters[{index}].Quarter")));
}
for (var index = 1; index <= validated.Length; index++)
{
var value = validated[index - 1].Value;
var variant = value < 0 ? 2 : 1;
mutations.Add(new PlayoutSetVisible($"barG{index}_1", false));
mutations.Add(new PlayoutSetVisible($"barG{index}_2", false));
mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true));
mutations.Add(new PlayoutSetValue(
$"value{index}_{variant}",
value.ToString("#,##0", CultureInfo.InvariantCulture)));
}
var maximum = validated.Max(item => item.Value);
var minimum = validated.Min(item => item.Value);
var allPositive = maximum > 0 && minimum > 0;
var allNegative = maximum < 0 && minimum < 0;
var mixedDenominator = (double)maximum + Math.Abs((double)minimum);
var center = allPositive
? -240f
: allNegative
? 115f
: mixedDenominator == 0d
? -250f
: LegacyChartSafety.FiniteFloat(
(355d * Math.Abs((double)minimum) / mixedDenominator) - 250d,
"Quarterly sales center");
mutations.Add(new PlayoutSetPositionKey(
"centerbar",
0,
0,
center,
0,
PlayoutVectorComponents.Y));
var denominator = allPositive
? (double)maximum
: allNegative
? (double)minimum
: (double)maximum - minimum;
for (var index = 1; index <= validated.Length; index++)
{
var value = validated[index - 1].Value;
var scale = denominator == 0d
? 0f
: LegacyChartSafety.FiniteFloat(
1.8d * Math.Abs(value / denominator),
"Quarterly sales scale");
var variant = value < 0 ? 2 : 1;
mutations.Add(new PlayoutSetScale(
$"bar{index}_{variant}",
0,
scale,
0,
PlayoutVectorComponents.Y));
}
return mutations;
}
}
public enum InvestorFlowParticipant
{
Individual,
Institution,
Foreign
}
public enum InvestorFlowMarket
{
Kospi,
Kosdaq,
Kospi200
}
public sealed record InvestorFlowSummaryData(
InvestorFlowMarket Market,
int Value);
public sealed record InvestorFlowTrendPoint(
InvestorFlowMarket Market,
int Value,
TimeOnly DataTime);
public sealed record S5083SceneData(
InvestorFlowParticipant Participant,
IReadOnlyList<InvestorFlowSummaryData> Summary,
IReadOnlyList<InvestorFlowTrendPoint> TrendPoints) : ILegacySceneData;
public sealed class S5083SceneMutationBuilder : ILegacySceneMutationBuilder<S5083SceneData>
{
public string BuilderKey => "s5083";
public IReadOnlyList<PlayoutMutation> Build(S5083SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var title = data.Participant switch
{
InvestorFlowParticipant.Individual => "개인 매매동향",
InvestorFlowParticipant.Institution => "기관 매매동향",
InvestorFlowParticipant.Foreign => "외국인 매매동향",
_ => throw new LegacySceneDataException("Unknown investor flow participant.")
};
var summary = LegacySceneBuilderGuard.Count(data.Summary, 3, nameof(data.Summary));
var trendPoints = LegacySceneBuilderGuard.Range(
data.TrendPoints,
3,
10_000,
nameof(data.TrendPoints));
var seenSummaryMarkets = new HashSet<InvestorFlowMarket>();
foreach (var row in summary)
{
var validated = LegacySceneBuilderGuard.NotNull(row, nameof(data.Summary));
_ = MarketSlot(validated.Market);
if (!seenSummaryMarkets.Add(validated.Market))
{
throw new LegacySceneDataException("Investor flow summary markets must be unique.");
}
}
var grouped = new Dictionary<InvestorFlowMarket, List<int>>
{
[InvestorFlowMarket.Kospi] = [],
[InvestorFlowMarket.Kosdaq] = [],
[InvestorFlowMarket.Kospi200] = []
};
var maximum = 0d;
var minimum = 0d;
foreach (var point in trendPoints)
{
var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints));
_ = MarketSlot(validated.Market);
grouped[validated.Market].Add(validated.Value);
maximum = Math.Max(maximum, validated.Value);
minimum = Math.Min(minimum, validated.Value);
}
if (grouped.Values.Any(values => values.Count == 0))
{
throw new LegacySceneDataException("Every investor flow market requires trend data.");
}
var scale = LegacyIntradayChartScale.Create(maximum, minimum);
var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime);
var mutations = new List<PlayoutMutation>(8)
{
new PlayoutSetValue("title", title)
};
foreach (var row in summary)
{
mutations.Add(new PlayoutSetValue(
"price" + MarketSlot(row.Market),
row.Value.ToString("#,##0", CultureInfo.InvariantCulture)));
}
mutations.Add(new PlayoutSetPosition(
"baseline",
0,
scale.BaselinePosition,
0,
PlayoutVectorComponents.Y));
var kospi = grouped[InvestorFlowMarket.Kospi];
mutations.Add(new PlayoutSetPathPoints(
"path1",
BuildS5083Path(kospi, lineWidth, scale, S5083XMode.FirstPath, kospi.Count)));
mutations.Add(new PlayoutSetPathPoints(
"path2",
BuildS5083Path(
grouped[InvestorFlowMarket.Kosdaq],
lineWidth,
scale,
S5083XMode.FollowingPath,
kospi.Count)));
mutations.Add(new PlayoutSetPathPoints(
"path3",
BuildS5083Path(
grouped[InvestorFlowMarket.Kospi200],
lineWidth,
scale,
S5083XMode.FollowingPath,
grouped[InvestorFlowMarket.Kospi200].Count)));
return mutations;
}
private static IReadOnlyList<PlayoutPoint> BuildS5083Path(
IReadOnlyList<int> values,
double lineWidth,
LegacyIntradayChartScale scale,
S5083XMode xMode,
int denominatorCount)
{
var points = new List<PlayoutPoint>(values.Count + 1)
{
new(0, scale.Baseline, 0)
};
for (var index = 0; index < values.Count; index++)
{
double x;
if (xMode == S5083XMode.FirstPath)
{
x = values.Count == 1
? 0d
: lineWidth / (values.Count - 1d) * index;
}
else
{
x = lineWidth / denominatorCount * (index + 1d);
}
points.Add(new PlayoutPoint(
LegacyChartSafety.FiniteFloat(x, "Investor flow X position"),
scale.Y(values[index]),
0));
}
return points;
}
private static int MarketSlot(InvestorFlowMarket market) => market switch
{
InvestorFlowMarket.Kospi => 1,
InvestorFlowMarket.Kosdaq => 2,
InvestorFlowMarket.Kospi200 => 3,
_ => throw new LegacySceneDataException("Unknown investor flow market.")
};
private enum S5083XMode
{
FirstPath,
FollowingPath
}
}
public enum InvestorFlowIndex
{
Kospi,
Kosdaq
}
public sealed record InvestorFlowTotalsData(
int Individual,
int Foreign,
int Institution);
public sealed record InvestorFlowIndexTrendPoint(
int Individual,
int Foreign,
int Institution,
TimeOnly DataTime);
public sealed record S5084SceneData(
InvestorFlowIndex Index,
InvestorFlowTotalsData Totals,
IReadOnlyList<InvestorFlowIndexTrendPoint> TrendPoints) : ILegacySceneData;
public sealed class S5084SceneMutationBuilder : ILegacySceneMutationBuilder<S5084SceneData>
{
public string BuilderKey => "s5084";
public IReadOnlyList<PlayoutMutation> Build(S5084SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var title = data.Index switch
{
InvestorFlowIndex.Kospi => "코스피 매매동향",
InvestorFlowIndex.Kosdaq => "코스닥 매매동향",
_ => throw new LegacySceneDataException("Unknown investor flow index.")
};
var totals = LegacySceneBuilderGuard.NotNull(data.Totals, nameof(data.Totals));
var trendPoints = LegacySceneBuilderGuard.Range(
data.TrendPoints,
1,
10_000,
nameof(data.TrendPoints));
var maximum = 0d;
var minimum = 0d;
foreach (var point in trendPoints)
{
var validated = LegacySceneBuilderGuard.NotNull(point, nameof(data.TrendPoints));
maximum = Math.Max(
maximum,
Math.Max(validated.Individual, Math.Max(validated.Foreign, validated.Institution)));
minimum = Math.Min(
minimum,
Math.Min(validated.Individual, Math.Min(validated.Foreign, validated.Institution)));
}
var scale = LegacyIntradayChartScale.Create(maximum, minimum);
var lineWidth = LegacyIntradayChartScale.LineWidth(trendPoints[^1].DataTime);
var mutations = new List<PlayoutMutation>(8)
{
new PlayoutSetValue("title", title),
new PlayoutSetValue(
"price1",
totals.Individual.ToString("#,##0", CultureInfo.InvariantCulture)),
new PlayoutSetValue(
"price2",
totals.Foreign.ToString("#,##0", CultureInfo.InvariantCulture)),
new PlayoutSetValue(
"price3",
totals.Institution.ToString("#,##0", CultureInfo.InvariantCulture)),
new PlayoutSetPosition(
"baseline",
0,
scale.BaselinePosition,
0,
PlayoutVectorComponents.Y),
new PlayoutSetPathPoints(
"path1",
BuildS5084Path(trendPoints.Select(item => item.Individual).ToArray(), lineWidth, scale)),
new PlayoutSetPathPoints(
"path2",
BuildS5084Path(trendPoints.Select(item => item.Foreign).ToArray(), lineWidth, scale)),
new PlayoutSetPathPoints(
"path3",
BuildS5084Path(trendPoints.Select(item => item.Institution).ToArray(), lineWidth, scale))
};
return mutations;
}
private static IReadOnlyList<PlayoutPoint> BuildS5084Path(
IReadOnlyList<int> values,
double lineWidth,
LegacyIntradayChartScale scale)
{
var points = new List<PlayoutPoint>(values.Count + 1)
{
new(0, scale.Baseline, 0)
};
for (var index = 0; index < values.Count; index++)
{
points.Add(new PlayoutPoint(
LegacyChartSafety.FiniteFloat(
lineWidth / values.Count * (index + 1d),
"Index flow X position"),
scale.Y(values[index]),
0));
}
return points;
}
}
internal sealed record LegacyIntradayChartScale(
double Minimum,
double Range,
float Baseline,
float BaselinePosition)
{
private const double GraphMinimum = -138d;
private const double GraphHeight = 266d;
private const double GraphOffset = 48d;
private const double DegenerateMidpoint = -5d;
private const double FullLineWidth = 1212.26d;
public static LegacyIntradayChartScale Create(double maximum, double minimum)
{
var range = maximum - minimum;
if (!double.IsFinite(maximum) || !double.IsFinite(minimum) || !double.IsFinite(range))
{
throw new LegacySceneDataException("Intraday chart range must be finite.");
}
var baseline = range == 0d
? DegenerateMidpoint
: GraphMinimum + ((0d - minimum) / range * GraphHeight);
var baselinePosition = Convert.ToInt32(GraphOffset + baseline);
return new LegacyIntradayChartScale(
minimum,
range,
LegacyChartSafety.FiniteFloat(baseline, "Intraday baseline"),
baselinePosition);
}
public static double LineWidth(TimeOnly time)
{
var minutes = (time.Hour * 60) + time.Minute;
if (minutes >= 930)
{
return FullLineWidth;
}
// The legacy formula becomes negative before 09:00. Clamp to the physical graph.
var progress = Math.Clamp((minutes - 540d) / 390d, 0d, 1d);
return FullLineWidth * progress;
}
public float Y(double value)
{
var y = Range == 0d
? DegenerateMidpoint
: GraphMinimum + ((value - Minimum) / Range * GraphHeight);
return LegacyChartSafety.FiniteFloat(y, "Intraday Y position");
}
}
internal static class LegacyChartSafety
{
public static float FiniteFloat(double value, string name)
{
if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue)
{
throw new LegacySceneDataException(name + " must be a finite float.");
}
return (float)value;
}
}

View File

@@ -0,0 +1,909 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record S5078SceneDataRequest(SectorIndexMarket Market);
public sealed class S5078SceneDataLoader
{
private static readonly string[] Columns =
["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"];
private readonly IDataQueryExecutor _executor;
public S5078SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5078SceneData> LoadAsync(
S5078SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var spec = S5078QueryFactory.Create(request.Market);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5078_SECTOR_INDEX",
spec,
cancellationToken).ConfigureAwait(false);
var rows = ChartSceneDataLoaderReader.Rows(table, Columns, 10, 10);
var mapped = new SectorIndexBarData[rows.Count];
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
// These values were returned and displayed by the original query even though
// only RATE participates in the K3D mutation. Validate them fail-closed.
_ = ChartSceneDataLoaderReader.Double(row, "PART_INDEX");
_ = ChartSceneDataLoaderReader.Double(row, "PART_CHANGE");
var changeType = ChartSceneDataLoaderReader.Text(
row,
"CHG_TYPE",
allowEmpty: true).Trim();
mapped[index] = new SectorIndexBarData(
ChartSceneDataLoaderReader.Text(row, "CATEGORY"),
ChartSceneDataLoaderReader.Double(row, "RATE"),
changeType == "-"
? SectorBarVariant.Primary
: SectorBarVariant.Secondary);
}
var data = new S5078SceneData(request.Market, mapped);
_ = new S5078SceneMutationBuilder().Build(data);
return data;
}
}
internal static class S5078QueryFactory
{
private const string KospiQuery = """
SELECT
b.f_part_name CATEGORY,
a.F_PART_IDX / 100 PART_INDEX,
a.F_CHG_TYPE CHG_TYPE,
a.F_PART_CHG / 100 PART_CHANGE,
ROUND(
a.f_part_chg /
DECODE(
a.f_chg_type,
'+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
'-', (a.f_part_idx / 100) + (a.f_part_chg / 100)),
2) RATE
FROM t_index a, t_part b
WHERE a.f_part_code = b.f_part_code
AND a.f_part_code IN
('013', '009', '011', '016', '018', '021', '008', '007', '015', '005')
ORDER BY a.f_part_code
""";
private const string KosdaqQuery = """
SELECT
b.f_part_name CATEGORY,
a.F_PART_IDX / 100 PART_INDEX,
a.F_CHG_TYPE CHG_TYPE,
a.F_PART_CHG / 100 PART_CHANGE,
ROUND(
a.f_part_chg /
DECODE(
a.f_chg_type,
'+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
'-', (a.f_part_idx / 100) + (a.f_part_chg / 100)),
2) RATE
FROM t_kosdaq_index a, t_kosdaq_part b
WHERE a.f_part_code = b.f_part_code
AND a.f_part_code IN
('159', '160', '066', '154', '065', '031', '029', '070', '024', '153')
ORDER BY a.f_part_code
""";
private const string DowQuery = """
SELECT
a.f_input_name CATEGORY,
ROUND(b.f_last, 2) PART_INDEX,
b.f_sign CHG_TYPE,
ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
ROUND(b.f_rate, 2) RATE
FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
WHERE a.f_symb = b.f_symb
AND a.f_symb IN
('DJI@DJINET', 'DJI@DJUSAU', 'DJI@DJUSCH', 'DJI@DJUSNF', 'DJI@DJUSCN',
'DJI@DJT', 'DJI@DJU', 'DJI@DJUSFB', 'DJI@DJUSCFT', 'DJI@DJUSEE')
""";
private const string NasdaqQuery = """
SELECT
a.f_input_name CATEGORY,
ROUND(b.f_last, 2) PART_INDEX,
b.f_sign CHG_TYPE,
ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
ROUND(b.f_rate, 2) RATE
FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
WHERE a.f_symb = b.f_symb
AND a.f_symb IN
('NAS@CXBT', 'NAS@NDX', 'NAS@NQSSSE', 'NAS@IXBK', 'NAS@IXIS',
'NAS@IXUT', 'NAS@IXK', 'NAS@NBI', 'NAS@IXF', 'NAS@NDXT')
""";
private const string StandardAndPoorQuery = """
SELECT
a.f_input_name CATEGORY,
ROUND(b.f_last, 2) PART_INDEX,
b.f_sign CHG_TYPE,
ABS(ROUND(b.f_diff, 2)) PART_CHANGE,
ROUND(b.f_rate, 2) RATE
FROM t_world_ix_eq_master a, t_world_ix_eq_sise b
WHERE a.f_symb = b.f_symb
AND a.f_symb IN
('SPI@S5ENRS', 'SPI@S5RLST', 'SPI@S5AUCO', 'SPI@S5INFT', 'SPI@S5COND',
'SPI@S5INSU', 'SPI@S5ELUTX', 'SPI@S5HLTH', 'SPI@S5FINL', 'SPI@S5TELS')
""";
public static DataQuerySpec Create(SectorIndexMarket market)
{
var sql = market switch
{
SectorIndexMarket.Dow => DowQuery,
SectorIndexMarket.Nasdaq => NasdaqQuery,
SectorIndexMarket.StandardAndPoor => StandardAndPoorQuery,
SectorIndexMarket.Kospi => KospiQuery,
SectorIndexMarket.Kosdaq => KosdaqQuery,
_ => throw ChartSceneDataLoaderReader.InvalidRequest()
};
return ChartSceneDataLoaderReader.Spec(sql);
}
}
public sealed record S5079SceneDataRequest(string StockName);
public sealed class S5079SceneDataLoader
{
private static readonly string[] Columns =
["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"];
private const string Query = """
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE
FROM INPUT_GROW
WHERE STOCK_NAME = :stockName
""";
private readonly IDataQueryExecutor _executor;
public S5079SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5079SceneData> LoadAsync(
S5079SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var stockName = ChartSceneDataLoaderReader.Selection(request.StockName);
var spec = ChartSceneDataLoaderReader.Spec(
Query,
new DataQueryParameter("stockName", stockName, DbType.String));
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"INPUT_GROW",
spec,
cancellationToken).ConfigureAwait(false);
var row = ChartSceneDataLoaderReader.SingleRow(table, Columns);
var series = new GrowthMetricSeriesData[4];
foreach (var path in Enum.GetValues<GrowthMetricPath>())
{
var column = "GUSUNG_" + (((int)path) + 1).ToString(CultureInfo.InvariantCulture);
series[(int)path] = new GrowthMetricSeriesData(
path,
ParseGrowthSeries(ChartSceneDataLoaderReader.Text(row, column, allowEmpty: true)));
}
var dates = ChartSceneDataLoaderReader.Text(row, "BASE_DATE", allowEmpty: true)
.Split('_', StringSplitOptions.None);
if (dates.Length != 4)
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
for (var index = 0; index < dates.Length; index++)
{
_ = LegacySceneBuilderGuard.Text(
dates[index],
$"Dates[{index}]",
allowEmpty: true);
}
var data = new S5079SceneData(
ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"),
series,
dates);
_ = new S5079SceneMutationBuilder().Build(data);
return data;
}
private static IReadOnlyList<double?> ParseGrowthSeries(string value)
{
var tokens = value.Split('_', StringSplitOptions.None);
if (tokens.Length != 4)
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
var values = new double?[4];
for (var index = 0; index < tokens.Length; index++)
{
if (tokens[index].Length == 0)
{
values[index] = null;
continue;
}
if (!double.TryParse(
tokens[index],
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var parsed) ||
!double.IsFinite(parsed))
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
values[index] = parsed;
}
return values;
}
}
public sealed record S5080SceneDataRequest(string StockName);
public sealed class S5080SceneDataLoader
{
private static readonly string[] Columns =
[
"STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3",
"GUSUNG_4", "GUSUNG_5", "GUSUNG_6"
];
private const string Query = """
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6
FROM INPUT_SELL
WHERE STOCK_NAME = :stockName
""";
private readonly IDataQueryExecutor _executor;
public S5080SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5080SceneData> LoadAsync(
S5080SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var stockName = ChartSceneDataLoaderReader.Selection(request.StockName);
var spec = ChartSceneDataLoaderReader.Spec(
Query,
new DataQueryParameter("stockName", stockName, DbType.String));
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"INPUT_SELL",
spec,
cancellationToken).ConfigureAwait(false);
var row = ChartSceneDataLoaderReader.SingleRow(table, Columns);
var quarters = new QuarterlySalesData[6];
for (var index = 0; index < quarters.Length; index++)
{
var raw = ChartSceneDataLoaderReader.Text(
row,
"GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture),
allowEmpty: true);
quarters[index] = ParseQuarter(raw);
}
var data = new S5080SceneData(
ChartSceneDataLoaderReader.Text(row, "STOCK_NAME"),
quarters);
_ = new S5080SceneMutationBuilder().Build(data);
return data;
}
private static QuarterlySalesData ParseQuarter(string value)
{
var tokens = value.Split('_', StringSplitOptions.None);
if (tokens.Length != 2 || string.IsNullOrWhiteSpace(tokens[0]))
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
var number = 0;
if (tokens[1].Length != 0 &&
!int.TryParse(
tokens[1],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out number))
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
return new QuarterlySalesData(tokens[0], number);
}
}
public sealed record S5083SceneDataRequest(InvestorFlowParticipant Participant);
public sealed class S5083SceneDataLoader
{
private static readonly string[] SummaryColumns = ["MARKET", "AMOUNT"];
private static readonly string[] TrendColumns = ["MARKET", "DATA_TIME", "PRICE"];
private readonly IDataQueryExecutor _executor;
public S5083SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5083SceneData> LoadAsync(
S5083SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var plan = S5083QueryFactory.Create(request.Participant);
var summaryTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5083_SUMMARY",
plan.Summary,
cancellationToken).ConfigureAwait(false);
var trendTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5083_TREND",
plan.Trend,
cancellationToken).ConfigureAwait(false);
var summaryRows = ChartSceneDataLoaderReader.Rows(summaryTable, SummaryColumns, 3, 3);
var summary = summaryRows
.Select(row => new InvestorFlowSummaryData(
ChartSceneDataLoaderReader.Market(row, "MARKET"),
ChartSceneDataLoaderReader.Int32(row, "AMOUNT")))
.ToArray();
var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 3, 10_000);
var trend = trendRows
.Select(row => new InvestorFlowTrendPoint(
ChartSceneDataLoaderReader.Market(row, "MARKET"),
ChartSceneDataLoaderReader.Int32(row, "PRICE"),
ChartSceneDataLoaderReader.Time(row, "DATA_TIME")))
.ToArray();
var data = new S5083SceneData(request.Participant, summary, trend);
_ = new S5083SceneMutationBuilder().Build(data);
return data;
}
}
internal sealed record S5083QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend);
internal static class S5083QueryFactory
{
private const string IndividualSummary = """
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE = '8000'
UNION
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_kosdaq_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE = '8000'
UNION
SELECT '200' MARKET,
ROUND((SUM(F_SELL_turnover) / 100000000) -
(SUM(F_BUY_turnover) / 100000000)) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE = '8000'
""";
private const string IndividualTrend = """
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE = '8000'
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
UNION
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
FROM t_kosdaq_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE = '8000'
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
UNION
SELECT '200' MARKET,
f_data_time DATA_TIME,
ROUND((F_SELL_turnover - F_BUY_turnover) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE = '8000'
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
ORDER BY MARKET, DATA_TIME
""";
private const string InstitutionSummary = """
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
GROUP BY f_part_code
UNION
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_kosdaq_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
GROUP BY f_part_code
UNION
SELECT '200' MARKET,
ROUND((SUM(F_SELL_turnover) / 100000000) -
(SUM(F_BUY_turnover) / 100000000)) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
""";
private const string InstitutionTrend = """
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
UNION
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_kosdaq_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
UNION
SELECT '200' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE IN ('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
ORDER BY MARKET, DATA_TIME
""";
private const string ForeignSummary = """
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('9000', '9001')
UNION
SELECT '' MARKET,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) AMOUNT
FROM t_kosdaq_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('9000', '9001')
UNION
SELECT '200' MARKET,
ROUND((SUM(F_SELL_turnover) / 100000000) -
(SUM(F_BUY_turnover) / 100000000)) AMOUNT
FROM t_invest
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE IN ('9000', '9001')
""";
private const string ForeignTrend = """
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('9000', '9001')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
UNION
SELECT '' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_kosdaq_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN ('9000', '9001')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
UNION
SELECT '200' MARKET,
f_data_time DATA_TIME,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000) PRICE
FROM t_invest_his
WHERE F_PART_CODE = '029'
AND F_INVEST_CODE IN ('9000', '9001')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time
ORDER BY MARKET, DATA_TIME
""";
public static S5083QueryPlan Create(InvestorFlowParticipant participant)
{
var queries = participant switch
{
InvestorFlowParticipant.Individual => (IndividualSummary, IndividualTrend),
InvestorFlowParticipant.Institution => (InstitutionSummary, InstitutionTrend),
InvestorFlowParticipant.Foreign => (ForeignSummary, ForeignTrend),
_ => throw ChartSceneDataLoaderReader.InvalidRequest()
};
return new S5083QueryPlan(
ChartSceneDataLoaderReader.Spec(queries.Item1),
ChartSceneDataLoaderReader.Spec(queries.Item2));
}
}
public sealed record S5084SceneDataRequest(InvestorFlowIndex Index);
public sealed class S5084SceneDataLoader
{
private static readonly string[] SummaryColumns =
["INDIVIDUAL", "FOREIGN", "INSTITUTION"];
private static readonly string[] TrendColumns =
["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"];
private readonly IDataQueryExecutor _executor;
public S5084SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5084SceneData> LoadAsync(
S5084SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var plan = S5084QueryFactory.Create(request.Index);
var summaryTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5084_SUMMARY",
plan.Summary,
cancellationToken).ConfigureAwait(false);
var trendTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5084_TREND",
plan.Trend,
cancellationToken).ConfigureAwait(false);
var summaryRow = ChartSceneDataLoaderReader.SingleRow(summaryTable, SummaryColumns);
var totals = new InvestorFlowTotalsData(
ChartSceneDataLoaderReader.Int32(summaryRow, "INDIVIDUAL"),
ChartSceneDataLoaderReader.Int32(summaryRow, "FOREIGN"),
ChartSceneDataLoaderReader.Int32(summaryRow, "INSTITUTION"));
var trendRows = ChartSceneDataLoaderReader.Rows(trendTable, TrendColumns, 1, 10_000);
var trend = trendRows
.Select(row => new InvestorFlowIndexTrendPoint(
ChartSceneDataLoaderReader.Int32(row, "INDIVIDUAL"),
ChartSceneDataLoaderReader.Int32(row, "FOREIGN"),
ChartSceneDataLoaderReader.Int32(row, "INSTITUTION"),
ChartSceneDataLoaderReader.Time(row, "DATA_TIME")))
.ToArray();
var data = new S5084SceneData(request.Index, totals, trend);
_ = new S5084SceneMutationBuilder().Build(data);
return data;
}
}
internal sealed record S5084QueryPlan(DataQuerySpec Summary, DataQuerySpec Trend);
internal static class S5084QueryFactory
{
private const string KospiSummary = """
SELECT
SUM(DECODE(
f_invest_code,
'8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0)) INDIVIDUAL,
SUM(DECODE(
f_invest_code,
'9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
'9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0)) FOREIGN,
SUM(DECODE(
f_invest_code,
'8000', 0,
'9000', 0,
'9001', 0,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION
FROM t_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN
('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
'8000', '9000', '9001')
GROUP BY f_invest_code
""";
private const string KosdaqSummary = """
SELECT
SUM(DECODE(
f_invest_code,
'8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0)) INDIVIDUAL,
SUM(DECODE(
f_invest_code,
'9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
'9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0)) FOREIGN,
SUM(DECODE(
f_invest_code,
'8000', 0,
'9000', 0,
'9001', 0,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000))) INSTITUTION
FROM t_kosdaq_invest
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN
('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
'8000', '9000', '9001')
GROUP BY f_invest_code
""";
private const string KospiTrend = """
SELECT
f_data_time DATA_TIME,
SUM(p_1) INDIVIDUAL,
SUM(p_2) FOREIGN,
SUM(p_3) INSTITUTION
FROM (
SELECT
f_data_time,
DECODE(
f_invest_code,
'8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0) p_1,
DECODE(
f_invest_code,
'9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
'9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0) p_2,
DECODE(
f_invest_code,
'8000', 0,
'9000', 0,
'9001', 0,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3
FROM t_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN
('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
'8000', '9000', '9001')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time, f_invest_code
)
GROUP BY f_data_time
ORDER BY f_data_time
""";
private const string KosdaqTrend = """
SELECT
f_data_time DATA_TIME,
SUM(p_1) INDIVIDUAL,
SUM(p_2) FOREIGN,
SUM(p_3) INSTITUTION
FROM (
SELECT
f_data_time,
DECODE(
f_invest_code,
'8000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0) p_1,
DECODE(
f_invest_code,
'9000', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
'9001', ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000),
0) p_2,
DECODE(
f_invest_code,
'8000', 0,
'9000', 0,
'9001', 0,
ROUND((SUM(F_SELL_turnover) - SUM(F_BUY_turnover)) / 100000000)) p_3
FROM t_kosdaq_invest_his
WHERE F_PART_CODE = '001'
AND F_INVEST_CODE IN
('1000', '2000', '3000', '3100', '4000', '5000', '6000', '7000',
'8000', '9000', '9001')
AND f_data_time LIKE (SELECT MAX(open_day) || '%' FROM v_open_day)
GROUP BY f_data_time, f_invest_code
)
GROUP BY f_data_time
ORDER BY f_data_time
""";
public static S5084QueryPlan Create(InvestorFlowIndex index)
{
var queries = index switch
{
InvestorFlowIndex.Kospi => (KospiSummary, KospiTrend),
InvestorFlowIndex.Kosdaq => (KosdaqSummary, KosdaqTrend),
_ => throw ChartSceneDataLoaderReader.InvalidRequest()
};
return new S5084QueryPlan(
ChartSceneDataLoaderReader.Spec(queries.Item1),
ChartSceneDataLoaderReader.Spec(queries.Item2));
}
}
internal static class ChartSceneDataLoaderReader
{
private const int MaximumSelectionLength = 256;
private const int MaximumTextLength = 4_096;
public static DataQuerySpec Spec(string sql, params DataQueryParameter[] parameters)
{
var spec = new DataQuerySpec(sql, parameters);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
public static string Selection(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > MaximumSelectionLength ||
value.Any(char.IsControl))
{
throw InvalidRequest();
}
return value;
}
public static DataRow SingleRow(DataTable? table, IReadOnlyList<string> columns) =>
Rows(table, columns, 1, 1)[0];
public static IReadOnlyList<DataRow> Rows(
DataTable? table,
IReadOnlyList<string> columns,
int minimum,
int maximum)
{
if (table is null || table.Columns.Count != columns.Count ||
table.Rows.Count < minimum || table.Rows.Count > maximum)
{
throw InvalidResult();
}
for (var index = 0; index < columns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.OrdinalIgnoreCase))
{
throw InvalidResult();
}
}
return table.Rows.Cast<DataRow>().ToArray();
}
public static string Text(DataRow row, string column, bool allowEmpty = false)
{
if (row[column] is not { } value || value is DBNull)
{
throw InvalidResult();
}
string text;
try
{
text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
}
catch (Exception exception) when (exception is FormatException or InvalidCastException)
{
throw InvalidResult();
}
if (text.Length > MaximumTextLength || text.Any(char.IsControl) ||
(!allowEmpty && string.IsNullOrWhiteSpace(text)))
{
throw InvalidResult();
}
return text;
}
public static double Double(DataRow row, string column)
{
try
{
var value = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture);
return double.IsFinite(value) ? value : throw InvalidResult();
}
catch (Exception exception) when (
exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static int Int32(DataRow row, string column)
{
try
{
var value = Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture);
if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue)
{
throw InvalidResult();
}
return decimal.ToInt32(value);
}
catch (Exception exception) when (
exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static InvestorFlowMarket Market(DataRow row, string column) =>
Text(row, column).Trim() switch
{
"코스피" => InvestorFlowMarket.Kospi,
"코스닥" => InvestorFlowMarket.Kosdaq,
"코스피200" => InvestorFlowMarket.Kospi200,
_ => throw InvalidResult()
};
public static TimeOnly Time(DataRow row, string column)
{
var value = Value(row, column);
if (value is DateTime dateTime)
{
return TimeOnly.FromDateTime(dateTime);
}
var text = Text(row, column).Trim();
if (text.Length < 4)
{
throw InvalidResult();
}
var timeText = text[^4..];
if (!TimeOnly.TryParseExact(
timeText,
"HHmm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var time))
{
throw InvalidResult();
}
return time;
}
private static object Value(DataRow row, string column) =>
row[column] is { } value && value is not DBNull ? value : throw InvalidResult();
public static LegacySceneDataException InvalidRequest() =>
new("The chart scene data request is unsupported or invalid.");
public static LegacySceneDataException InvalidResult() =>
new("The chart scene query returned invalid data or an unexpected schema.");
}

View File

@@ -0,0 +1,335 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Closed result for MainForm's s5026/s5029/s5086/s50860/s5087 dispatch.
/// </summary>
public abstract record LegacyComparisonAndYieldSceneLoadRequest(string BuilderKey);
public sealed record LegacyS5026SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
: LegacyComparisonAndYieldSceneLoadRequest("s5026");
public sealed record LegacyS5029SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
: LegacyComparisonAndYieldSceneLoadRequest("s5029");
public sealed record LegacyS5086SceneLoadRequest(YieldSceneLoadRequest Request)
: LegacyComparisonAndYieldSceneLoadRequest("s5086");
public sealed record LegacyS50860SceneLoadRequest(YieldSceneLoadRequest Request)
: LegacyComparisonAndYieldSceneLoadRequest("s50860");
public sealed record LegacyS5087SceneLoadRequest(ComparisonPairSceneLoadRequest Request)
: LegacyComparisonAndYieldSceneLoadRequest("s5087");
/// <summary>
/// Converts the legacy playlist fields to the closed comparison/yield loader requests.
/// LegacySceneSelection maps to MainForm as GroupCode=code, Subject=jongmok,
/// GraphicType=forCutInfo, Subtype=sub and DataCode=dataCode. The five source
/// constructors use only code, jongmok and sub.
/// </summary>
public sealed class ComparisonAndYieldLegacyRequestResolver
{
private readonly IDataQueryExecutor _executor;
public ComparisonAndYieldLegacyRequestResolver(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<LegacyComparisonAndYieldSceneLoadRequest> ResolveAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.CutCode is not ("5026" or "5029" or "5086" or "50860" or "5087"))
{
throw InvalidSelection();
}
var selection = entry.Selection ?? throw InvalidSelection();
var subject = Required(selection.Subject);
return entry.CutCode switch
{
"5026" => new LegacyS5026SceneLoadRequest(
await ResolvePairAsync(
subject,
ComparisonGraphPeriod.FiveDays,
cancellationToken).ConfigureAwait(false)),
"5029" => new LegacyS5029SceneLoadRequest(
await ResolvePairAsync(
subject,
ParseComparisonPeriod(selection.Subtype),
cancellationToken).ConfigureAwait(false)),
"5087" => new LegacyS5087SceneLoadRequest(
await ResolvePairAsync(
subject,
ComparisonGraphPeriod.FiveDays,
cancellationToken).ConfigureAwait(false)),
"5086" => new LegacyS5086SceneLoadRequest(
await ResolveYieldAsync(
selection,
subject,
requireQuotedFx: false,
cancellationToken).ConfigureAwait(false)),
"50860" => new LegacyS50860SceneLoadRequest(
await ResolveYieldAsync(
selection,
subject,
requireQuotedFx: true,
cancellationToken).ConfigureAwait(false)),
_ => throw InvalidSelection()
};
}
private async Task<ComparisonPairSceneLoadRequest> ResolvePairAsync(
string subject,
ComparisonGraphPeriod period,
CancellationToken cancellationToken)
{
var stockNames = SplitPair(subject);
var first = await ResolveStockAsync(stockNames[0], cancellationToken)
.ConfigureAwait(false);
var second = await ResolveStockAsync(stockNames[1], cancellationToken)
.ConfigureAwait(false);
return new ComparisonPairSceneLoadRequest(period, first, second);
}
private async Task<YieldSceneLoadRequest> ResolveYieldAsync(
LegacySceneSelection selection,
string subject,
bool requireQuotedFx,
CancellationToken cancellationToken)
{
var period = ParseYieldPeriod(selection.Subtype);
var fxKind = TryParseFx(subject);
if (fxKind.HasValue)
{
return new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(fxKind.Value));
}
// The source s50860 reads quote cells which exist only in the FX result shape.
// Reject every other legacy branch before performing a stock-master lookup.
if (requireQuotedFx)
{
throw InvalidSelection();
}
var groupCode = selection.GroupCode?.Trim() ?? string.Empty;
if (subject == "지수")
{
var index = groupCode switch
{
"코스피" => YieldGraphInstrumentKind.KospiIndex,
"코스닥" => YieldGraphInstrumentKind.KosdaqIndex,
"코스피200" => YieldGraphInstrumentKind.Kospi200Index,
"KRX100" => YieldGraphInstrumentKind.Krx100Index,
_ => throw InvalidSelection()
};
return new YieldSceneLoadRequest(period, new YieldGraphInstrument(index));
}
if (groupCode.Contains("업종", StringComparison.Ordinal))
{
var industry = groupCode.Contains("코스피", StringComparison.Ordinal)
? YieldGraphInstrumentKind.KospiIndustry
: YieldGraphInstrumentKind.KosdaqIndustry;
return new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(industry, subject));
}
var stock = await ResolveStockAsync(subject, cancellationToken).ConfigureAwait(false);
var stockKind = stock.Market switch
{
ComparisonEquityMarket.Kospi => YieldGraphInstrumentKind.KospiStock,
ComparisonEquityMarket.Kosdaq => YieldGraphInstrumentKind.KosdaqStock,
_ => throw InvalidSelection()
};
return new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(stockKind, stock.StockName));
}
private async Task<ComparisonStockSelection> ResolveStockAsync(
string rawName,
CancellationToken cancellationToken)
{
var stockName = Required(rawName);
ComparisonEquityMarket? market = null;
if (await StockExistsAsync(
ComparisonEquityMarket.Kospi,
stockName,
cancellationToken).ConfigureAwait(false))
{
market = ComparisonEquityMarket.Kospi;
}
// MainForm scans the KOSDAQ cache after the KOSPI cache, so a later
// KOSDAQ match intentionally takes precedence for an overlapping name.
if (await StockExistsAsync(
ComparisonEquityMarket.Kosdaq,
stockName,
cancellationToken).ConfigureAwait(false))
{
market = ComparisonEquityMarket.Kosdaq;
}
return market.HasValue
? new ComparisonStockSelection(market.Value, stockName)
: throw InvalidSelection();
}
private async Task<bool> StockExistsAsync(
ComparisonEquityMarket market,
string stockName,
CancellationToken cancellationToken)
{
var query = StockMasterQuery(market, stockName);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
query.TableName,
query.Spec,
cancellationToken).ConfigureAwait(false);
if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 ||
!string.Equals(
table.Columns[0].ColumnName,
"MATCH_COUNT",
StringComparison.OrdinalIgnoreCase) ||
table.Rows[0][0] is null or DBNull)
{
throw InvalidResult();
}
decimal count;
try
{
count = Convert.ToDecimal(table.Rows[0][0], CultureInfo.InvariantCulture);
}
catch (Exception exception) when (exception is
FormatException or InvalidCastException or OverflowException)
{
_ = exception;
throw InvalidResult();
}
if (count != decimal.Truncate(count))
{
throw InvalidResult();
}
return count switch
{
0m => false,
1m => true,
_ => throw InvalidResult()
};
}
private static StockMasterLookup StockMasterQuery(
ComparisonEquityMarket market,
string stockName)
{
var parameter = new DataQueryParameter("stockName", stockName, DbType.String);
return market switch
{
ComparisonEquityMarket.Kospi => new StockMasterLookup(
"SCENE_COMPARISON_RESOLVE_KOSPI_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " +
"WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
[parameter])),
ComparisonEquityMarket.Kosdaq => new StockMasterLookup(
"SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " +
"WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
[parameter])),
_ => throw InvalidSelection()
};
}
private static ComparisonGraphPeriod ParseComparisonPeriod(string value) =>
Required(value) switch
{
"일봉" => ComparisonGraphPeriod.Daily,
"5일" => ComparisonGraphPeriod.FiveDays,
"1개월" => ComparisonGraphPeriod.OneMonth,
"3개월" => ComparisonGraphPeriod.ThreeMonths,
"6개월" => ComparisonGraphPeriod.SixMonths,
"12개월" => ComparisonGraphPeriod.TwelveMonths,
_ => throw InvalidSelection()
};
private static YieldGraphPeriod ParseYieldPeriod(string value) =>
Required(value) switch
{
"5일" => YieldGraphPeriod.FiveDays,
"20일" => YieldGraphPeriod.TwentyDays,
"60일" => YieldGraphPeriod.SixtyDays,
"120일" => YieldGraphPeriod.OneHundredTwentyDays,
"240일" => YieldGraphPeriod.TwoHundredFortyDays,
_ => throw InvalidSelection()
};
private static YieldGraphInstrumentKind? TryParseFx(string subject)
{
if (subject.Contains("원달러", StringComparison.Ordinal))
{
return YieldGraphInstrumentKind.WonDollar;
}
if (subject.Contains("원엔", StringComparison.Ordinal))
{
return YieldGraphInstrumentKind.WonYen;
}
if (subject.Contains("원위엔", StringComparison.Ordinal))
{
return YieldGraphInstrumentKind.WonYuan;
}
if (subject.Contains("원유로", StringComparison.Ordinal))
{
return YieldGraphInstrumentKind.WonEuro;
}
return null;
}
private static string[] SplitPair(string subject)
{
var values = subject.Split(',', StringSplitOptions.TrimEntries);
if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace))
{
throw InvalidSelection();
}
return values;
}
private static string Required(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw InvalidSelection();
}
return value.Trim();
}
private static LegacySceneDataException InvalidSelection() =>
new("The comparison or yield legacy selection is unsupported.");
private static LegacySceneDataException InvalidResult() =>
new("A comparison stock-master lookup returned an invalid result.");
private sealed record StockMasterLookup(string TableName, DataQuerySpec Spec);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,515 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record S5006DomesticSceneDataRequest(
LegacyDomesticEquityMarket Market,
string StockCode);
public sealed class S5006DomesticSceneDataLoader
{
private static readonly string[] Columns =
[
"STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
"OPEN_PRICE", "OPEN_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE"
];
private readonly IDataQueryExecutor _executor;
public S5006DomesticSceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5006SceneData> LoadAsync(
S5006DomesticSceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var view = request.Market switch
{
LegacyDomesticEquityMarket.Kospi => "V_2",
LegacyDomesticEquityMarket.Kosdaq => "V_2_KOSDAQ",
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode);
var sql = $"""
SELECT
a.stock_name STOCK_NAME,
a.chg_type DIRECTION_CODE,
a.curr_price CURRENT_PRICE,
a.net_chg NET_CHANGE,
a.rate RATE,
a.init_price OPEN_PRICE,
DECODE(
a.final_price,
0, ROUND(((a.init_price - a.base_price) / a.base_price) * 100, 2),
ROUND(((a.init_price - a.final_price) / a.final_price) * 100, 2)) OPEN_RATE,
a.high_price HIGH_PRICE,
DECODE(
a.final_price,
0, ROUND(((a.high_price - a.base_price) / a.base_price) * 100, 2),
ROUND(((a.high_price - a.final_price) / a.final_price) * 100, 2)) HIGH_RATE,
a.low_price LOW_PRICE,
DECODE(
a.final_price,
0, ROUND(((a.low_price - a.base_price) / a.base_price) * 100, 2),
ROUND(((a.low_price - a.final_price) / a.final_price) * 100, 2)) LOW_RATE
FROM {view} a
WHERE a.stock_code = :stockCode
""";
var spec = EquityPanelLoaderReader.Spec(
DataSourceKind.Oracle,
sql,
new DataQueryParameter("stockCode", stockCode, DbType.String));
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5006_DOMESTIC",
spec,
cancellationToken).ConfigureAwait(false);
var row = EquityPanelLoaderReader.SingleRow(table, Columns);
var data = new S5006SceneData(
request.Market,
EquityPanelLoaderReader.Text(row, "STOCK_NAME"),
EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"),
EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"),
EquityPanelLoaderReader.Int64(row, "NET_CHANGE"),
EquityPanelLoaderReader.Decimal(row, "RATE"),
EquityPanelLoaderReader.Int64(row, "OPEN_PRICE"),
EquityPanelLoaderReader.Decimal(row, "OPEN_RATE"),
EquityPanelLoaderReader.Int64(row, "HIGH_PRICE"),
EquityPanelLoaderReader.Decimal(row, "HIGH_RATE"),
EquityPanelLoaderReader.Int64(row, "LOW_PRICE"),
EquityPanelLoaderReader.Decimal(row, "LOW_RATE"));
_ = new S5006SceneMutationBuilder().Build(data);
return data;
}
}
public sealed record S5011SceneDataRequest(
LegacyDomesticEquityMarket Market,
S5011DetailBranch Branch,
string StockCode);
public sealed class S5011SceneDataLoader
{
private static readonly string[] Columns =
[
"STOCK_NAME", "DIRECTION_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE",
"DETAIL_1", "DETAIL_2", "DETAIL_3", "DETAIL_4"
];
private readonly IDataQueryExecutor _executor;
public S5011SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5011SceneData> LoadAsync(
S5011SceneDataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!Enum.IsDefined(request.Market) || !Enum.IsDefined(request.Branch))
{
throw EquityPanelLoaderReader.InvalidRequest();
}
var stockCode = EquityPanelLoaderReader.StockCode(request.StockCode);
var plan = S5011QueryFactory.Create(request.Market, request.Branch, stockCode);
var table = await _executor.ExecuteAsync(
plan.Source,
"S5011_DETAIL",
plan.Spec,
cancellationToken).ConfigureAwait(false);
var row = EquityPanelLoaderReader.SingleRow(table, Columns);
S5011DetailData detail = request.Branch switch
{
S5011DetailBranch.FaceValue => new S5011FaceValueDetail(
EquityPanelLoaderReader.Int64(row, "DETAIL_1"),
EquityPanelLoaderReader.Int64(row, "DETAIL_2"),
EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
S5011DetailBranch.Valuation => new S5011ValuationDetail(
EquityPanelLoaderReader.Decimal(row, "DETAIL_1"),
EquityPanelLoaderReader.Decimal(row, "DETAIL_2"),
EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
S5011DetailBranch.Volume => new S5011VolumeDetail(
EquityPanelLoaderReader.Int64(row, "DETAIL_1"),
EquityPanelLoaderReader.Int64(row, "DETAIL_2"),
EquityPanelLoaderReader.Int64(row, "DETAIL_3"),
EquityPanelLoaderReader.Int64(row, "DETAIL_4")),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var data = new S5011SceneData(
request.Market,
EquityPanelLoaderReader.Text(row, "STOCK_NAME"),
EquityPanelLoaderReader.Direction(row, "DIRECTION_CODE"),
EquityPanelLoaderReader.Int64(row, "CURRENT_PRICE"),
EquityPanelLoaderReader.Int64(row, "NET_CHANGE"),
EquityPanelLoaderReader.Decimal(row, "RATE"),
detail);
_ = new S5011SceneMutationBuilder().Build(data);
return data;
}
}
internal static class S5011QueryFactory
{
public static QueryPlan Create(
LegacyDomesticEquityMarket market,
S5011DetailBranch branch,
string stockCode)
{
if (market is LegacyDomesticEquityMarket.NxtKospi or
LegacyDomesticEquityMarket.NxtKosdaq)
{
if (branch == S5011DetailBranch.Valuation)
{
// The legacy NXT query fabricated four zero valuation values.
throw EquityPanelLoaderReader.InvalidRequest();
}
return Nxt(market, branch, stockCode);
}
return Oracle(market, branch, stockCode);
}
private static QueryPlan Oracle(
LegacyDomesticEquityMarket market,
S5011DetailBranch branch,
string stockCode)
{
var source = market switch
{
LegacyDomesticEquityMarket.Kospi => new OracleSource(
"V_2", "t_online1", "t_stock", "T_CANDLE_HISTORY"),
LegacyDomesticEquityMarket.Kosdaq => new OracleSource(
"V_2_KOSDAQ", "t_kosdaq_online1", "t_kosdaq_stock",
"T_KOSDAQ_CANDLE_HISTORY"),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var sql = branch switch
{
S5011DetailBranch.FaceValue => $"""
SELECT
a.stock_name STOCK_NAME,
a.chg_type DIRECTION_CODE,
a.curr_price CURRENT_PRICE,
a.net_chg NET_CHANGE,
a.rate RATE,
a.list_price DETAIL_1,
a.capital_price DETAIL_2,
a.siga_price DETAIL_3,
b.rank_num DETAIL_4
FROM {source.View} a,
(SELECT
z.f_stock_code stock_code,
RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num
FROM {source.OnlineTable} y, {source.StockTable} z
WHERE y.f_stock_code = z.f_stock_code
AND z.f_mkt_halt = 'N') b
WHERE a.stock_code = b.stock_code
AND a.stock_code = :stockCode
""",
S5011DetailBranch.Valuation => $"""
SELECT
a.stock_name STOCK_NAME,
a.chg_type DIRECTION_CODE,
a.curr_price CURRENT_PRICE,
a.net_chg NET_CHANGE,
a.rate RATE,
a.pbr DETAIL_1,
a.per DETAIL_2,
a.bps DETAIL_3,
a.eps DETAIL_4
FROM {source.View} a
WHERE a.stock_code = :stockCode
""",
S5011DetailBranch.Volume => $"""
SELECT
a.stock_name STOCK_NAME,
a.chg_type DIRECTION_CODE,
a.curr_price CURRENT_PRICE,
a.net_chg NET_CHANGE,
a.rate RATE,
a.net_vol DETAIL_1,
a.net_turnover DETAIL_2,
NVL((
SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 5)
FROM (
SELECT F_CURR_PRICE
FROM (
SELECT F_CURR_PRICE
FROM {source.CandleTable}
WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day)
AND f_stock_code = :stockCode5
ORDER BY F_DATA_DATE DESC
)
WHERE ROWNUM < 5
) recent
), 0) DETAIL_3,
NVL((
SELECT ROUND((SUM(recent.F_CURR_PRICE) + a.curr_price) / 20)
FROM (
SELECT F_CURR_PRICE
FROM (
SELECT F_CURR_PRICE
FROM {source.CandleTable}
WHERE F_DATA_DATE < (SELECT MAX(open_day) FROM v_open_day)
AND f_stock_code = :stockCode20
ORDER BY F_DATA_DATE DESC
)
WHERE ROWNUM < 20
) recent
), 0) DETAIL_4
FROM {source.View} a
WHERE a.stock_code = :stockCode
""",
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var parameters = branch == S5011DetailBranch.Volume
? new[]
{
new DataQueryParameter("stockCode5", stockCode, DbType.String),
new DataQueryParameter("stockCode20", stockCode, DbType.String),
new DataQueryParameter("stockCode", stockCode, DbType.String)
}
: [new DataQueryParameter("stockCode", stockCode, DbType.String)];
return new QueryPlan(
DataSourceKind.Oracle,
EquityPanelLoaderReader.Spec(DataSourceKind.Oracle, sql, parameters));
}
private static QueryPlan Nxt(
LegacyDomesticEquityMarket market,
S5011DetailBranch branch,
string stockCode)
{
var source = market switch
{
LegacyDomesticEquityMarket.NxtKospi => new NxtSource(
"n_online", "n_stock"),
LegacyDomesticEquityMarket.NxtKosdaq => new NxtSource(
"n_kosdaq_online", "n_kosdaq_stock"),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var sql = branch switch
{
S5011DetailBranch.FaceValue => $"""
SELECT
b.f_stock_name STOCK_NAME,
a.f_chg_type DIRECTION_CODE,
a.f_curr_price CURRENT_PRICE,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / CASE
WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg)
END) * 100, 2) RATE,
b.f_list_price DETAIL_1,
b.f_capital_price DETAIL_2,
b.f_list_num * a.f_curr_price DETAIL_3,
b.rank_num DETAIL_4
FROM {source.OnlineTable} a
JOIN (
SELECT
z.f_stock_code,
z.f_stock_name,
z.f_list_price,
z.f_capital_price,
z.f_list_num,
RANK() OVER (ORDER BY z.f_list_num * y.f_curr_price DESC) rank_num
FROM {source.OnlineTable} y
JOIN {source.StockTable} z ON y.f_stock_code = z.f_stock_code
WHERE z.f_stop_gubun = 'N'
) b ON a.f_stock_code = b.f_stock_code
WHERE a.f_stock_code = @stockCode
""",
S5011DetailBranch.Volume => $"""
SELECT
z.f_stock_name STOCK_NAME,
a.f_chg_type DIRECTION_CODE,
a.f_curr_price CURRENT_PRICE,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / CASE
WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg)
END) * 100, 2) RATE,
a.f_net_vol DETAIL_1,
a.f_net_turnover DETAIL_2,
0 DETAIL_3,
0 DETAIL_4
FROM {source.OnlineTable} a
JOIN {source.StockTable} z ON a.f_stock_code = z.f_stock_code
WHERE a.f_stock_code = @stockCode
""",
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
return new QueryPlan(
DataSourceKind.MariaDb,
EquityPanelLoaderReader.Spec(
DataSourceKind.MariaDb,
sql,
new DataQueryParameter("stockCode", stockCode, DbType.String)));
}
internal sealed record QueryPlan(DataSourceKind Source, DataQuerySpec Spec);
private sealed record OracleSource(
string View,
string OnlineTable,
string StockTable,
string CandleTable);
private sealed record NxtSource(string OnlineTable, string StockTable);
}
internal static class EquityPanelLoaderReader
{
private const int MaximumTextLength = 256;
public static DataQuerySpec Spec(
DataSourceKind source,
string sql,
params DataQueryParameter[] parameters)
{
var spec = new DataQuerySpec(sql, parameters);
spec.ValidateFor(source);
return spec;
}
public static string StockCode(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw InvalidRequest();
}
return value;
}
public static DataRow SingleRow(DataTable? table, IReadOnlyList<string> columns)
{
ExactSchema(table, columns);
if (table!.Rows.Count != 1)
{
throw InvalidResult();
}
return table.Rows[0];
}
public static void ExactSchema(DataTable? table, IReadOnlyList<string> columns)
{
if (table is null || table.Columns.Count != columns.Count)
{
throw InvalidResult();
}
for (var index = 0; index < columns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.OrdinalIgnoreCase))
{
throw InvalidResult();
}
}
}
public static string Text(DataRow row, string column, bool allowNull = false)
{
var value = row[column];
if (value is null or DBNull)
{
if (allowNull)
{
return string.Empty;
}
throw InvalidResult();
}
var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
if (text is null || text.Length > MaximumTextLength || text.Any(char.IsControl) ||
(!allowNull && text.Length == 0))
{
throw InvalidResult();
}
return text;
}
public static object Value(DataRow row, string column) =>
row[column] is { } value && value is not DBNull ? value : throw InvalidResult();
public static long Int64(DataRow row, string column)
{
try
{
return checked(Convert.ToInt64(Value(row, column), CultureInfo.InvariantCulture));
}
catch (Exception exception) when (
exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static decimal Decimal(DataRow row, string column)
{
try
{
return Convert.ToDecimal(Value(row, column), CultureInfo.InvariantCulture);
}
catch (Exception exception) when (
exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static double Double(DataRow row, string column)
{
try
{
var result = Convert.ToDouble(Value(row, column), CultureInfo.InvariantCulture);
return double.IsFinite(result) ? result : throw InvalidResult();
}
catch (Exception exception) when (
exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static ScenePriceDirection Direction(DataRow row, string column) =>
Direction(Text(row, column, allowNull: true));
public static ScenePriceDirection Direction(string? value) => value?.Trim() switch
{
"\uC0C1\uD55C" or "1" => ScenePriceDirection.LimitUp,
"\uC0C1\uC2B9" or "+" or "2" => ScenePriceDirection.Up,
null or "" or "\uBCF4\uD569" or "3" => ScenePriceDirection.Flat,
"\uD558\uB77D" or "-" or "5" => ScenePriceDirection.Down,
"\uD558\uD55C" or "4" => ScenePriceDirection.LimitDown,
_ => throw InvalidResult()
};
public static LegacySceneDataException InvalidRequest() =>
new("The panel scene data request is unsupported or invalid.");
public static LegacySceneDataException InvalidResult() =>
new("The panel scene query returned invalid data or an unexpected schema.");
}

View File

@@ -0,0 +1,457 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record RevenueSliceData(
string Label,
string PercentageText,
double Percentage);
public sealed record S5076SceneData(
string StockName,
string Basis,
IReadOnlyList<RevenueSliceData?> Slices) : ILegacySceneData;
public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder<S5076SceneData>
{
public string BuilderKey => "s5076";
public IReadOnlyList<PlayoutMutation> Build(S5076SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var basis = LegacySceneBuilderGuard.Text(data.Basis, nameof(data.Basis));
var slices = LegacySceneBuilderGuard.Count(data.Slices, 5, nameof(data.Slices));
var mutations = new List<PlayoutMutation>(34)
{
new PlayoutSetValue("title", stockName + " 주요매출 구성"),
new PlayoutSetValue("date", basis + " 기준")
};
for (var index = 1; index <= 5; index++)
{
mutations.Add(new PlayoutSetVisible("circle" + index, false));
mutations.Add(new PlayoutSetVisible("group" + index, false));
}
double startAngle = 0;
for (var index = 1; index <= 5; index++)
{
var slice = slices[index - 1];
if (slice is null || string.IsNullOrEmpty(slice.Label))
{
continue;
}
var label = LegacySceneBuilderGuard.Text(slice.Label, $"Slices[{index - 1}].Label");
var percentageText = LegacySceneBuilderGuard.Text(
slice.PercentageText,
$"Slices[{index - 1}].PercentageText");
if (!double.IsFinite(slice.Percentage))
{
throw new LegacySceneDataException("Revenue percentage must be finite.");
}
mutations.Add(new PlayoutSetVisible("circle" + index, true));
mutations.Add(new PlayoutSetVisible("group" + index, true));
mutations.Add(new PlayoutSetValue("word" + index, label));
mutations.Add(new PlayoutSetValue("percent" + index, percentageText));
var angle = slice.Percentage / 100d * 360d;
mutations.Add(new PlayoutSetCircleAngleKey(
"circle" + index,
0,
(float)startAngle,
(float)startAngle,
PlayoutAngleComponents.All));
mutations.Add(new PlayoutSetCircleAngleKey(
"circle" + index,
1,
(float)startAngle,
(float)(startAngle + angle),
PlayoutAngleComponents.All));
startAngle += angle;
}
return mutations;
}
}
public sealed record QuarterlyProfitData(string Quarter, int Value);
public sealed record S5081SceneData(
string StockName,
IReadOnlyList<QuarterlyProfitData> Quarters) : ILegacySceneData;
public sealed class S5081SceneMutationBuilder : ILegacySceneMutationBuilder<S5081SceneData>
{
public string BuilderKey => "s5081";
public IReadOnlyList<PlayoutMutation> Build(S5081SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var quarters = LegacySceneBuilderGuard.Count(data.Quarters, 6, nameof(data.Quarters));
var mutations = new List<PlayoutMutation>(38)
{
new PlayoutSetValue("title", stockName + " 영업이익")
};
for (var index = 1; index <= 6; index++)
{
var quarter = LegacySceneBuilderGuard.NotNull(
quarters[index - 1],
$"Quarters[{index - 1}]");
mutations.Add(new PlayoutSetValue(
"quarter" + index,
LegacySceneBuilderGuard.Text(quarter.Quarter, $"Quarters[{index - 1}].Quarter")));
}
var values = quarters.Select(item => item.Value).ToArray();
for (var index = 1; index <= 6; index++)
{
var value = values[index - 1];
var variant = value < 0 ? 2 : 1;
mutations.Add(new PlayoutSetVisible($"barG{index}_1", false));
mutations.Add(new PlayoutSetVisible($"barG{index}_2", false));
mutations.Add(new PlayoutSetVisible($"barG{index}_{variant}", true));
mutations.Add(new PlayoutSetValue(
$"value{index}_{variant}",
value.ToString("#,##0", CultureInfo.InvariantCulture)));
}
var maximum = values.Max();
var minimum = values.Min();
var allPositive = maximum > 0 && minimum > 0;
var allNegative = maximum < 0 && minimum < 0;
var center = allPositive
? -240f
: allNegative
? 115f
: SafeFloat((355d * Math.Abs(minimum) / (maximum + Math.Abs((double)minimum))) - 250d, -250f);
mutations.Add(new PlayoutSetPositionKey(
"centerbar",
0,
0,
center,
0,
PlayoutVectorComponents.Y));
for (var index = 1; index <= 6; index++)
{
var value = values[index - 1];
var denominator = allPositive
? maximum
: allNegative
? minimum
: maximum - minimum;
var scale = denominator == 0
? 0f
: (float)(1.8d * Math.Abs(value / (double)denominator));
var variant = value < 0 ? 2 : 1;
mutations.Add(new PlayoutSetScale(
$"bar{index}_{variant}",
0,
scale,
0,
PlayoutVectorComponents.Y));
}
return mutations;
}
private static float SafeFloat(double value, float fallback) =>
double.IsFinite(value) ? (float)value : fallback;
}
public enum ProgramTradingMetric
{
NetBuy,
Arbitrage,
NonArbitrage,
Basis,
Kospi200,
Futures
}
public sealed record ProgramTradingMetricData(ProgramTradingMetric Metric, double Value);
public sealed record S5085SceneData(
IReadOnlyList<ProgramTradingMetricData> Metrics) : ILegacySceneData;
public sealed class S5085SceneMutationBuilder : ILegacySceneMutationBuilder<S5085SceneData>
{
public string BuilderKey => "s5085";
public IReadOnlyList<PlayoutMutation> Build(S5085SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var metrics = LegacySceneBuilderGuard.Range(data.Metrics, 1, 6, nameof(data.Metrics));
if (metrics.Select(item => item.Metric).Distinct().Count() != metrics.Count)
{
throw new LegacySceneDataException("Program trading metrics must be unique.");
}
var mutations = new List<PlayoutMutation>(metrics.Count * 3);
foreach (var metric in metrics)
{
if (!double.IsFinite(metric.Value))
{
throw new LegacySceneDataException("Program trading value must be finite.");
}
var (index, title) = metric.Metric switch
{
ProgramTradingMetric.NetBuy => (1, "순매수"),
ProgramTradingMetric.Arbitrage => (2, "차익"),
ProgramTradingMetric.NonArbitrage => (3, "비차익"),
ProgramTradingMetric.Basis => (4, "베이시스"),
ProgramTradingMetric.Kospi200 => (5, "코스피200"),
ProgramTradingMetric.Futures => (6, "선물"),
_ => throw new LegacySceneDataException("Unknown program trading metric.")
};
var color = SignedColor(metric.Value, (171, 0, 0), (15, 99, 188), (47, 47, 47));
mutations.Add(new PlayoutSetValue("title" + index, title));
mutations.Add(new PlayoutSetFaceColor("price" + index, color.R, color.G, color.B));
mutations.Add(new PlayoutSetValue(
"price" + index,
metric.Value.ToString("#,##0.##", CultureInfo.InvariantCulture)));
}
return mutations;
}
private static (int R, int G, int B) SignedColor(
double value,
(int R, int G, int B) positive,
(int R, int G, int B) negative,
(int R, int G, int B) zero) => value > 0 ? positive : value < 0 ? negative : zero;
}
public enum InstitutionMarket
{
Kospi,
Kosdaq,
Other
}
public enum InstitutionKind
{
Securities,
Insurance,
InvestmentTrust,
Bank,
Fund
}
public sealed record InstitutionNetBuyData(
InstitutionMarket Market,
InstitutionKind Institution,
int Value);
public sealed record S6067SceneData(
IReadOnlyList<InstitutionNetBuyData> Rows) : ILegacySceneData;
public sealed class S6067SceneMutationBuilder : ILegacySceneMutationBuilder<S6067SceneData>
{
public string BuilderKey => "s6067";
public IReadOnlyList<PlayoutMutation> Build(S6067SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var rows = LegacySceneBuilderGuard.Range(data.Rows, 1, 15, nameof(data.Rows));
var mutations = new List<PlayoutMutation>(rows.Count * 2);
foreach (var row in rows)
{
var market = row.Market switch
{
InstitutionMarket.Kospi => 1,
InstitutionMarket.Kosdaq => 2,
InstitutionMarket.Other => 3,
_ => throw new LegacySceneDataException("Unknown institution market.")
};
var institution = row.Institution switch
{
InstitutionKind.Securities => 1,
InstitutionKind.Insurance => 2,
InstitutionKind.InvestmentTrust => 3,
InstitutionKind.Bank => 4,
InstitutionKind.Fund => 5,
_ => throw new LegacySceneDataException("Unknown institution type.")
};
var name = $"row{market}_value{institution}";
var color = row.Value > 0
? (R: 171, G: 0, B: 0)
: row.Value < 0
? (R: 15, G: 99, B: 188)
: (R: 47, G: 47, B: 47);
mutations.Add(new PlayoutSetFaceColor(name, color.R, color.G, color.B));
mutations.Add(new PlayoutSetValue(
name,
row.Value.ToString("#,##0", CultureInfo.InvariantCulture)));
}
return mutations;
}
}
public enum WorldMarket
{
Korea,
China,
HongKong,
Japan,
Taiwan,
Dow,
Nasdaq,
StandardAndPoor,
UnitedKingdom,
Germany,
France
}
public enum SceneMarketTrend
{
Up,
Flat,
Down
}
public sealed record WorldMarketQuoteData(
WorldMarket Market,
double CurrentPrice,
double NetChange,
double Rate,
SceneMarketTrend Trend);
public sealed record S8067SceneData(
IReadOnlyList<WorldMarketQuoteData> Quotes) : ILegacySceneData;
public sealed class S8067SceneMutationBuilder : ILegacySceneMutationBuilder<S8067SceneData>
{
public string BuilderKey => "s8067";
public IReadOnlyList<PlayoutMutation> Build(S8067SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var quotes = LegacySceneBuilderGuard.Range(data.Quotes, 1, 11, nameof(data.Quotes));
if (quotes.Select(item => item.Market).Distinct().Count() != quotes.Count)
{
throw new LegacySceneDataException("World market quotes must be unique.");
}
var mutations = new List<PlayoutMutation>(quotes.Count * 7);
foreach (var quote in quotes)
{
if (!double.IsFinite(quote.CurrentPrice) || !double.IsFinite(quote.NetChange) ||
!double.IsFinite(quote.Rate))
{
throw new LegacySceneDataException("World market quote values must be finite.");
}
var target = NameOf(quote.Market);
mutations.Add(new PlayoutSetValue(
"price_" + target,
quote.CurrentPrice.ToString("#,##0.###", CultureInfo.InvariantCulture)));
mutations.Add(new PlayoutSetValue(
"rate_" + target,
quote.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
mutations.Add(new PlayoutSetVisible("up_" + target, false));
mutations.Add(new PlayoutSetVisible("down_" + target, false));
mutations.Add(new PlayoutSetVisible("flat_" + target, false));
if (quote.Trend == SceneMarketTrend.Up)
{
mutations.Add(new PlayoutSetFaceColor("bg_" + target, 170, 0, 0));
mutations.Add(new PlayoutSetVisible("up_" + target, true));
}
else if (quote.Trend == SceneMarketTrend.Down && quote.NetChange != 0)
{
mutations.Add(new PlayoutSetFaceColor("bg_" + target, 1, 101, 255));
mutations.Add(new PlayoutSetVisible("down_" + target, true));
}
else
{
// Preserve the original global "bg" target used for flat rows.
mutations.Add(new PlayoutSetFaceColor("bg", 191, 191, 191));
mutations.Add(new PlayoutSetVisible("flat_" + target, true));
}
}
return mutations;
}
private static string NameOf(WorldMarket market) => market switch
{
WorldMarket.Korea => "한국",
WorldMarket.China => "중국",
WorldMarket.HongKong => "홍콩",
WorldMarket.Japan => "일본",
WorldMarket.Taiwan => "대만",
WorldMarket.Dow => "다우",
WorldMarket.Nasdaq => "나스닥",
WorldMarket.StandardAndPoor => "S&P",
WorldMarket.UnitedKingdom => "영국",
WorldMarket.Germany => "독일",
WorldMarket.France => "프랑스",
_ => throw new LegacySceneDataException("Unknown world market.")
};
}
public enum CommodityUnit
{
DollarsPerBarrel,
DollarsPerOunce
}
public sealed record S8086SceneData(
string Title,
CommodityUnit Unit,
double CurrentPrice,
double ChangePrice,
double Rate,
SceneMarketTrend Trend) : ILegacySceneData;
public sealed class S8086SceneMutationBuilder : ILegacySceneMutationBuilder<S8086SceneData>
{
public string BuilderKey => "s8086";
public IReadOnlyList<PlayoutMutation> Build(S8086SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
if (!double.IsFinite(data.CurrentPrice) || !double.IsFinite(data.ChangePrice) ||
!double.IsFinite(data.Rate))
{
throw new LegacySceneDataException("Commodity quote values must be finite.");
}
var unit = data.Unit switch
{
CommodityUnit.DollarsPerBarrel => "단위: 달러/배럴",
CommodityUnit.DollarsPerOunce => "단위: 달러/온스",
_ => throw new LegacySceneDataException("Unknown commodity unit.")
};
var color = data.Trend switch
{
SceneMarketTrend.Up => (R: 172, G: 0, B: 0),
SceneMarketTrend.Down => (R: 14, G: 100, B: 188),
SceneMarketTrend.Flat => (R: 95, G: 95, B: 95),
_ => throw new LegacySceneDataException("Unknown commodity trend.")
};
return
[
new PlayoutSetValue("unit", unit),
new PlayoutSetValue("title", title),
new PlayoutSetValue("price", data.CurrentPrice.ToString("#,##0.00", CultureInfo.InvariantCulture)),
new PlayoutSetValue("changePrice", data.ChangePrice.ToString("#,##0.00", CultureInfo.InvariantCulture)),
new PlayoutSetValue("rate", data.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)),
new PlayoutSetFaceColor("bg", color.R, color.G, color.B),
new PlayoutSetFaceColor("changePrice", color.R, color.G, color.B),
new PlayoutSetFaceColor("rate", color.R, color.G, color.B)
];
}
}

View File

@@ -0,0 +1,662 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record S5023SceneLoadRequest(LegacyDomesticMarket Market);
public sealed class S5023SceneDataLoader
{
private static readonly string[] DailyColumns =
["TRADING_DATE", "INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
private static readonly string[] TotalColumns =
["INDIVIDUAL_AMOUNT", "FOREIGN_AMOUNT", "INSTITUTION_AMOUNT"];
private readonly IDataQueryExecutor _executor;
public S5023SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5023SceneData> LoadAsync(
S5023SceneLoadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var plan = GridMarketSceneQueries.S5023(request.Market);
var dailyTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5023_DAILY",
plan.Daily,
cancellationToken).ConfigureAwait(false);
var totalTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5023_MONTHLY",
plan.Monthly,
cancellationToken).ConfigureAwait(false);
var dailyRows = LegacyTabularSceneLoaderReader.ExactRows(dailyTable, 5, DailyColumns);
var mappedRows = new DailyInvestorFlowData?[dailyRows.Count];
DateOnly? previous = null;
for (var index = 0; index < dailyRows.Count; index++)
{
var row = dailyRows[index];
var date = LegacyTabularSceneLoaderReader.Date(row, "TRADING_DATE");
if (previous.HasValue && date >= previous.Value)
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
previous = date;
mappedRows[index] = new DailyInvestorFlowData(
date,
new InvestorFlowAmounts(
LegacyTabularSceneLoaderReader.Int32(row, "INDIVIDUAL_AMOUNT"),
LegacyTabularSceneLoaderReader.Int32(row, "FOREIGN_AMOUNT"),
LegacyTabularSceneLoaderReader.Int32(row, "INSTITUTION_AMOUNT")));
}
var total = LegacyTabularSceneLoaderReader.ExactRows(totalTable, 1, TotalColumns)[0];
var data = new S5023SceneData(
request.Market,
mappedRows,
new InvestorFlowAmounts(
LegacyTabularSceneLoaderReader.Int32(total, "INDIVIDUAL_AMOUNT"),
LegacyTabularSceneLoaderReader.Int32(total, "FOREIGN_AMOUNT"),
LegacyTabularSceneLoaderReader.Int32(total, "INSTITUTION_AMOUNT")));
return LegacyTabularSceneLoaderReader.Preflight(new S5023SceneMutationBuilder(), data);
}
}
public sealed record S5024SceneLoadRequest(LegacyDomesticMarket Market);
public sealed class S5024SceneDataLoader
{
private static readonly string[] Columns = ["PARTICIPANT_CODE", "AMOUNT"];
private static readonly string[] ExpectedCodes = ["INDIVIDUAL", "FOREIGN", "INSTITUTION"];
private readonly IDataQueryExecutor _executor;
public S5024SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5024SceneData> LoadAsync(
S5024SceneLoadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5024",
GridMarketSceneQueries.S5024(request.Market),
cancellationToken).ConfigureAwait(false);
var rows = LegacyTabularSceneLoaderReader.ExactRows(table, 3, Columns);
var amounts = new InvestorTradingAmountData[rows.Count];
for (var index = 0; index < rows.Count; index++)
{
var code = LegacyTabularSceneLoaderReader.Text(rows[index], "PARTICIPANT_CODE");
if (!string.Equals(code, ExpectedCodes[index], StringComparison.Ordinal))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
var participant = code switch
{
"INDIVIDUAL" => InvestorParticipant.Individual,
"FOREIGN" => InvestorParticipant.Foreign,
"INSTITUTION" => InvestorParticipant.Institution,
_ => throw LegacyTabularSceneLoaderReader.InvalidResult()
};
amounts[index] = new InvestorTradingAmountData(
participant,
LegacyTabularSceneLoaderReader.Double(rows[index], "AMOUNT"));
}
var data = new S5024SceneData(request.Market, amounts);
return LegacyTabularSceneLoaderReader.Preflight(new S5024SceneMutationBuilder(), data);
}
}
public sealed class S5085SceneDataLoader
{
private static readonly string[] Columns = ["METRIC_CODE", "METRIC_VALUE"];
private static readonly (string Code, ProgramTradingMetric Metric)[] Expected =
[
("NET_BUY", ProgramTradingMetric.NetBuy),
("ARBITRAGE", ProgramTradingMetric.Arbitrage),
("NON_ARBITRAGE", ProgramTradingMetric.NonArbitrage),
("BASIS", ProgramTradingMetric.Basis),
("KOSPI200", ProgramTradingMetric.Kospi200),
("FUTURES", ProgramTradingMetric.Futures)
];
private readonly IDataQueryExecutor _executor;
public S5085SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5085SceneData> LoadAsync(CancellationToken cancellationToken = default)
{
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5085",
GridMarketSceneQueries.S5085,
cancellationToken).ConfigureAwait(false);
var rows = LegacyTabularSceneLoaderReader.ExactRows(table, Expected.Length, Columns);
var metrics = new ProgramTradingMetricData[rows.Count];
for (var index = 0; index < rows.Count; index++)
{
var code = LegacyTabularSceneLoaderReader.Text(rows[index], "METRIC_CODE");
if (!string.Equals(code, Expected[index].Code, StringComparison.Ordinal))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
metrics[index] = new ProgramTradingMetricData(
Expected[index].Metric,
LegacyTabularSceneLoaderReader.Double(rows[index], "METRIC_VALUE"));
}
var data = new S5085SceneData(metrics);
return LegacyTabularSceneLoaderReader.Preflight(new S5085SceneMutationBuilder(), data);
}
}
public sealed class S6067SceneDataLoader
{
private static readonly string[] Columns = ["MARKET_CODE", "INSTITUTION_CODE", "AMOUNT"];
private static readonly (string MarketCode, InstitutionMarket Market)[] Markets =
[
("KOSPI", InstitutionMarket.Kospi),
("KOSDAQ", InstitutionMarket.Kosdaq),
("KOSPI200", InstitutionMarket.Other)
];
private static readonly (string InstitutionCode, InstitutionKind Institution)[] Institutions =
[
("SECURITIES", InstitutionKind.Securities),
("INSURANCE", InstitutionKind.Insurance),
("INVESTMENT_TRUST", InstitutionKind.InvestmentTrust),
("BANK", InstitutionKind.Bank),
("FUND", InstitutionKind.Fund)
];
private readonly IDataQueryExecutor _executor;
public S6067SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S6067SceneData> LoadAsync(CancellationToken cancellationToken = default)
{
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_6067",
GridMarketSceneQueries.S6067,
cancellationToken).ConfigureAwait(false);
var rows = LegacyTabularSceneLoaderReader.ExactRows(
table,
Markets.Length * Institutions.Length,
Columns);
var mapped = new InstitutionNetBuyData[rows.Count];
for (var index = 0; index < rows.Count; index++)
{
var marketIndex = index / Institutions.Length;
var institutionIndex = index % Institutions.Length;
var row = rows[index];
var marketCode = LegacyTabularSceneLoaderReader.Text(row, "MARKET_CODE");
var institutionCode = LegacyTabularSceneLoaderReader.Text(row, "INSTITUTION_CODE");
if (!string.Equals(marketCode, Markets[marketIndex].MarketCode, StringComparison.Ordinal) ||
!string.Equals(
institutionCode,
Institutions[institutionIndex].InstitutionCode,
StringComparison.Ordinal))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
mapped[index] = new InstitutionNetBuyData(
Markets[marketIndex].Market,
Institutions[institutionIndex].Institution,
LegacyTabularSceneLoaderReader.Int32(row, "AMOUNT"));
}
var data = new S6067SceneData(mapped);
return LegacyTabularSceneLoaderReader.Preflight(new S6067SceneMutationBuilder(), data);
}
}
internal static class GridMarketSceneQueries
{
public static (DataQuerySpec Daily, DataQuerySpec Monthly) S5023(LegacyDomesticMarket market)
{
var (currentTable, historyTable) = market switch
{
LegacyDomesticMarket.Kospi => ("t_invest", "t_invest_his"),
LegacyDomesticMarket.Kosdaq => ("t_kosdaq_invest", "t_kosdaq_invest_his"),
_ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
};
var current = CurrentInvestorFlow(currentTable);
var history = HistoryInvestorFlow(historyTable, 5);
var monthlyHistory = HistoryInvestorFlow(historyTable, 20);
var daily = $"""
SELECT
trading_date TRADING_DATE,
individual_amount INDIVIDUAL_AMOUNT,
foreign_amount FOREIGN_AMOUNT,
institution_amount INSTITUTION_AMOUNT
FROM (
{current}
UNION
{history}
)
ORDER BY trading_date DESC
""";
var monthly = $"""
SELECT
SUM(individual_amount) INDIVIDUAL_AMOUNT,
SUM(foreign_amount) FOREIGN_AMOUNT,
SUM(institution_amount) INSTITUTION_AMOUNT
FROM (
{current}
UNION
{monthlyHistory}
)
""";
return (Oracle(daily), Oracle(monthly));
}
public static DataQuerySpec S5024(LegacyDomesticMarket market)
{
var table = market switch
{
LegacyDomesticMarket.Kospi => "t_invest",
LegacyDomesticMarket.Kosdaq => "t_kosdaq_invest",
_ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
};
return Oracle($"""
SELECT participant_code PARTICIPANT_CODE, amount AMOUNT
FROM (
SELECT
DECODE(
f_invest_code,
'8000', 'INDIVIDUAL',
'9000', 'FOREIGN',
'9001', 'FOREIGN',
'INSTITUTION') participant_code,
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
FROM {table}
WHERE f_part_code = '001'
AND f_invest_code IN (
'1000', '2000', '3000', '3100', '4000', '5000',
'6000', '7000', '8000', '9000', '9001')
GROUP BY DECODE(
f_invest_code,
'8000', 'INDIVIDUAL',
'9000', 'FOREIGN',
'9001', 'FOREIGN',
'INSTITUTION')
)
ORDER BY DECODE(participant_code, 'INDIVIDUAL', 1, 'FOREIGN', 2, 3)
""");
}
public static DataQuerySpec S5085 { get; } = Oracle("""
WITH future_row AS (
SELECT DECODE(
SIGN(a.f_curr_price - a.f_base_price),
-1, (a.f_curr_price / 100) * -1,
a.f_curr_price / 100) futures_value
FROM t_sunmul_online a
JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code
WHERE a.f_stock_seq = 1
AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day)
AND b.f_month_gubun = '1'
FETCH FIRST 1 ROW ONLY
)
SELECT metric_code METRIC_CODE, metric_value METRIC_VALUE
FROM (
SELECT 1 sort_order, 'NET_BUY' metric_code,
ROUND((
(f_bcon_buy_w_amt + f_bcon_buy_j_amt) -
(f_bcon_sell_w_amt + f_bcon_sell_j_amt) +
(f_ccon_buy_w_amt + f_ccon_buy_j_amt) -
(f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000) metric_value
FROM t_pgm_tot
UNION ALL
SELECT 2, 'ARBITRAGE',
ROUND(((f_ccon_buy_w_amt + f_ccon_buy_j_amt) -
(f_ccon_sell_w_amt + f_ccon_sell_j_amt)) / 100000000)
FROM t_pgm_tot
UNION ALL
SELECT 3, 'NON_ARBITRAGE',
ROUND(((f_bcon_buy_w_amt + f_bcon_buy_j_amt) -
(f_bcon_sell_w_amt + f_bcon_sell_j_amt)) / 100000000)
FROM t_pgm_tot
UNION ALL
SELECT 4, 'BASIS', futures_value - kospi200_value
FROM (
SELECT DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100) kospi200_value
FROM t_200_index
WHERE f_part_code = '029'
), future_row
UNION ALL
SELECT 5, 'KOSPI200',
DECODE(f_chg_type, '-', (f_part_idx / 100) * -1, f_part_idx / 100)
FROM t_200_index
WHERE f_part_code = '029'
UNION ALL
SELECT 6, 'FUTURES', futures_value
FROM future_row
)
ORDER BY sort_order
""");
public static DataQuerySpec S6067 { get; } = Oracle("""
SELECT market_code MARKET_CODE, institution_code INSTITUTION_CODE, amount AMOUNT
FROM (
SELECT
1 market_order,
DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5) institution_order,
'KOSPI' market_code,
DECODE(
f_invest_code,
'1000', 'SECURITIES',
'2000', 'INSURANCE',
'3000', 'INVESTMENT_TRUST',
'4000', 'BANK',
'6000', 'FUND') institution_code,
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
FROM t_invest
WHERE f_part_code = '001'
AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
GROUP BY f_invest_code
UNION ALL
SELECT
2,
DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5),
'KOSDAQ',
DECODE(
f_invest_code,
'1000', 'SECURITIES',
'2000', 'INSURANCE',
'3000', 'INVESTMENT_TRUST',
'4000', 'BANK',
'6000', 'FUND'),
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000)
FROM t_kosdaq_invest
WHERE f_part_code = '001'
AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
GROUP BY f_invest_code
UNION ALL
SELECT
3,
DECODE(f_invest_code, '1000', 1, '2000', 2, '3000', 3, '4000', 4, 5),
'KOSPI200',
DECODE(
f_invest_code,
'1000', 'SECURITIES',
'2000', 'INSURANCE',
'3000', 'INVESTMENT_TRUST',
'4000', 'BANK',
'6000', 'FUND'),
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000)
FROM t_invest
WHERE f_part_code = '029'
AND f_invest_code IN ('1000', '2000', '3000', '4000', '6000')
GROUP BY f_invest_code
)
ORDER BY market_order, institution_order
""");
private static string CurrentInvestorFlow(string table) => $"""
SELECT
TO_CHAR((SELECT MAX(open_day) FROM v_open_day)) trading_date,
individual_amount,
foreign_amount,
institution_amount
FROM (
SELECT
SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount,
SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount,
SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount
FROM (
SELECT
f_invest_code,
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
FROM {table}
WHERE f_part_code = '001'
AND f_invest_code IN (
'1000', '2000', '3000', '3100', '4000', '5000',
'6000', '7000', '8000', '9000', '9001')
GROUP BY f_invest_code
)
)
""";
private static string HistoryInvestorFlow(string table, int exclusiveRowNumber) => $"""
SELECT
SUBSTR(f_data_time, 1, 8) trading_date,
SUM(CASE WHEN f_invest_code = '8000' THEN amount ELSE 0 END) individual_amount,
SUM(CASE WHEN f_invest_code IN ('9000', '9001') THEN amount ELSE 0 END) foreign_amount,
SUM(CASE WHEN f_invest_code NOT IN ('8000', '9000', '9001') THEN amount ELSE 0 END) institution_amount
FROM (
SELECT
f_data_time,
f_invest_code,
ROUND((SUM(f_sell_turnover) - SUM(f_buy_turnover)) / 100000000) amount
FROM {table}
WHERE f_part_code = '001'
AND f_invest_code IN (
'1000', '2000', '3000', '3100', '4000', '5000',
'6000', '7000', '8000', '9000', '9001')
AND f_data_time IN (
SELECT data_time || '1535'
FROM (
SELECT open_day data_time FROM v_open_day
MINUS
SELECT SUBSTR(f_data_time, 1, 8) data_time
FROM t_index
WHERE f_part_code = '001'
ORDER BY data_time DESC
)
WHERE ROWNUM < {exclusiveRowNumber})
GROUP BY f_data_time, f_invest_code
)
GROUP BY SUBSTR(f_data_time, 1, 8)
""";
private static DataQuerySpec Oracle(string sql) =>
LegacyTabularSceneLoaderReader.Spec(DataSourceKind.Oracle, sql);
}
internal static class LegacyTabularSceneLoaderReader
{
private const int MaximumTextLength = 256;
private const int MaximumSelectorLength = 128;
public static DataQuerySpec Spec(
DataSourceKind source,
string sql,
params DataQueryParameter[] parameters)
{
var spec = new DataQuerySpec(sql, parameters);
spec.ValidateFor(source);
return spec;
}
public static IReadOnlyList<DataRow> ExactRows(
DataTable? table,
int rowCount,
IReadOnlyList<string> columns)
{
if (table is null || table.Rows.Count != rowCount || table.Columns.Count != columns.Count)
{
throw InvalidResult();
}
for (var index = 0; index < columns.Count; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.OrdinalIgnoreCase))
{
throw InvalidResult();
}
}
return table.Rows.Cast<DataRow>().ToArray();
}
public static string Text(DataRow row, string column, bool allowEmpty = false)
{
var text = Convert.ToString(Required(row, column), CultureInfo.InvariantCulture) ?? string.Empty;
if (text.Length > MaximumTextLength)
{
throw InvalidResult();
}
try
{
return LegacySceneBuilderGuard.Text(text, column, allowEmpty);
}
catch (LegacySceneDataException)
{
throw InvalidResult();
}
}
public static string Selector(string? value)
{
try
{
var selector = LegacySceneBuilderGuard.Text(value, "selector");
return selector.Length <= MaximumSelectorLength
? selector
: throw InvalidRequest();
}
catch (LegacySceneDataException)
{
throw InvalidRequest();
}
}
public static string StockCode(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw InvalidRequest();
}
return value;
}
public static DateOnly Date(DataRow row, string column)
{
var text = Text(row, column);
if (text.Length != 8 || text.Any(character => !char.IsAsciiDigit(character)) ||
!DateOnly.TryParseExact(
text,
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var value))
{
throw InvalidResult();
}
return value;
}
public static int Int32(DataRow row, string column)
{
var value = Decimal(row, column);
if (decimal.Truncate(value) != value || value is < int.MinValue or > int.MaxValue)
{
throw InvalidResult();
}
return decimal.ToInt32(value);
}
public static long Int64(DataRow row, string column)
{
var value = Decimal(row, column);
if (decimal.Truncate(value) != value || value is < long.MinValue or > long.MaxValue)
{
throw InvalidResult();
}
return decimal.ToInt64(value);
}
public static double Double(DataRow row, string column)
{
try
{
var value = Convert.ToDouble(Required(row, column), CultureInfo.InvariantCulture);
return double.IsFinite(value) ? value : throw InvalidResult();
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
public static ScenePriceDirection Direction(DataRow row, string column) =>
Text(row, column) switch
{
"1" => ScenePriceDirection.LimitUp,
"2" => ScenePriceDirection.Up,
"3" => ScenePriceDirection.Flat,
"4" => ScenePriceDirection.LimitDown,
"5" => ScenePriceDirection.Down,
_ => throw InvalidResult()
};
public static TData Preflight<TData>(
ILegacySceneMutationBuilder<TData> builder,
TData data)
where TData : ILegacySceneData
{
_ = builder.Build(data);
return data;
}
public static LegacySceneDataException InvalidRequest() =>
new("The scene data request is invalid.");
public static LegacySceneDataException InvalidResult() =>
new("The scene query returned an invalid result.");
private static decimal Decimal(DataRow row, string column)
{
try
{
return Convert.ToDecimal(Required(row, column), CultureInfo.InvariantCulture);
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
throw InvalidResult();
}
}
private static object Required(DataRow row, string column) =>
row[column] is { } value && value is not DBNull
? value
: throw InvalidResult();
}

View File

@@ -0,0 +1,169 @@
#nullable enable
using System.Globalization;
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record InvestorTradingValues(
int Individual,
int Foreign,
int Institution);
public sealed record S5082SceneData(
InvestorTradingValues Kospi,
InvestorTradingValues Kosdaq,
InvestorTradingValues Kospi200) : ILegacySceneData;
public sealed class S5082SceneMutationBuilder : ILegacySceneMutationBuilder<S5082SceneData>
{
public string BuilderKey => "s5082";
public IReadOnlyList<PlayoutMutation> Build(S5082SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
InvestorTradingValues[] rows =
[
LegacySceneBuilderGuard.NotNull(data.Kospi, nameof(data.Kospi)),
LegacySceneBuilderGuard.NotNull(data.Kosdaq, nameof(data.Kosdaq)),
LegacySceneBuilderGuard.NotNull(data.Kospi200, nameof(data.Kospi200))
];
var mutations = new List<PlayoutMutation>(54);
for (var rowIndex = 0; rowIndex < rows.Length; rowIndex++)
{
int[] values =
[
rows[rowIndex].Individual,
rows[rowIndex].Foreign,
rows[rowIndex].Institution
];
for (var columnIndex = 0; columnIndex < values.Length; columnIndex++)
{
var text = values[columnIndex].ToString("#,##0", CultureInfo.InvariantCulture);
var prefix = $"row{rowIndex + 1}_value{columnIndex + 1}_";
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetValue(prefix + variant, text));
}
var visibleVariant = values[columnIndex] > 0
? 1
: values[columnIndex] < 0
? 3
: 2;
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
prefix + variant,
variant == visibleVariant));
}
}
}
return mutations;
}
}
public sealed class S5082SceneDataLoader
{
private const string Query = """
SELECT name,
SUM(DECODE(invest, '', org_amt, 0)) individual_value,
SUM(DECODE(invest, '', org_amt, 0)) foreign_value,
SUM(DECODE(invest, '', org_amt, 0)) institution_value
FROM (
SELECT 1 s, '' name,
DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '') invest,
ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
FROM t_invest
WHERE f_part_code = '001'
AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
GROUP BY DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '')
UNION
SELECT 2 s, '' name,
DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '') invest,
ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
FROM t_kosdaq_invest
WHERE f_part_code = '001'
AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
GROUP BY DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '')
UNION
SELECT 3 s, '200' name,
DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '') invest,
ROUND((SUM(f_sell_turnover) / 100000000) - (SUM(f_buy_turnover) / 100000000)) org_amt
FROM t_invest
WHERE f_part_code = '029'
AND f_invest_code IN ('1000','2000','3000','3100','4000','5000','6000','7000','8000','9000','9001')
GROUP BY DECODE(f_invest_code, '8000', '', '9000', '', '9001', '', '')
)
GROUP BY name, s
ORDER BY s
""";
private readonly IDataQueryExecutor _executor;
public S5082SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5082SceneData> LoadAsync(
CancellationToken cancellationToken = default)
{
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5082",
new DataQuerySpec(Query),
cancellationToken).ConfigureAwait(false);
var rows = MapRows(table);
return new S5082SceneData(rows["코스피"], rows["코스닥"], rows["코스피200"]);
}
private static IReadOnlyDictionary<string, InvestorTradingValues> MapRows(DataTable? table)
{
string[] required = ["NAME", "INDIVIDUAL_VALUE", "FOREIGN_VALUE", "INSTITUTION_VALUE"];
if (table is null || table.Rows.Count != 3 || required.Any(name => !table.Columns.Contains(name)))
{
throw new LegacySceneDataException("Scene 5082 returned an unexpected result shape.");
}
var mapped = new Dictionary<string, InvestorTradingValues>(StringComparer.Ordinal);
foreach (DataRow row in table.Rows)
{
var name = Convert.ToString(row["NAME"], CultureInfo.InvariantCulture) ?? string.Empty;
if (name is not ("코스피" or "코스닥" or "코스피200") ||
!mapped.TryAdd(name, new InvestorTradingValues(
RequiredInt(row, "INDIVIDUAL_VALUE"),
RequiredInt(row, "FOREIGN_VALUE"),
RequiredInt(row, "INSTITUTION_VALUE"))))
{
throw new LegacySceneDataException("Scene 5082 returned an unknown or duplicate market row.");
}
}
if (mapped.Count != 3)
{
throw new LegacySceneDataException("Scene 5082 did not return every required market row.");
}
return mapped;
}
private static int RequiredInt(DataRow row, string column)
{
if (row[column] is null or DBNull)
{
throw new LegacySceneDataException("Scene 5082 returned a required null value.");
}
try
{
return checked(Convert.ToInt32(row[column], CultureInfo.InvariantCulture));
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
throw new LegacySceneDataException("Scene 5082 returned an invalid numeric value.");
}
}
}

View File

@@ -0,0 +1,406 @@
#nullable enable
using System.Data;
using System.IO;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Closed runtime plans for the legacy grid, trader, quote and trusted-manual cuts.
/// They contain typed loader requests only; Web input can never supply SQL, mutations,
/// COM operations or a manual-data path.
/// </summary>
public abstract record LegacyGridMarketSceneLoadRequest(string BuilderKey);
public sealed record LegacyS5023SceneLoadRequest(S5023SceneLoadRequest Request)
: LegacyGridMarketSceneLoadRequest("s5023");
public sealed record LegacyS5024SceneLoadRequest(S5024SceneLoadRequest Request)
: LegacyGridMarketSceneLoadRequest("s5024");
public sealed record LegacyS5025SceneLoadRequest(S5025ManualSceneLoadRequest Request)
: LegacyGridMarketSceneLoadRequest("s5025");
public sealed record LegacyS5037SceneLoadRequest(S5037SceneLoadRequest Request)
: LegacyGridMarketSceneLoadRequest("s5037");
public sealed record LegacyS5085SceneLoadRequest()
: LegacyGridMarketSceneLoadRequest("s5085");
public sealed record LegacyS6067SceneLoadRequest()
: LegacyGridMarketSceneLoadRequest("s6067");
public sealed record LegacyS8003SceneLoadRequest(S8003SceneLoadRequest Request)
: LegacyGridMarketSceneLoadRequest("s8003");
/// <summary>
/// Mirrors the original MainForm mapping of playlist code/jongmok/forCutInfo/sub to
/// <see cref="LegacySceneSelection.GroupCode"/>, <see cref="LegacySceneSelection.Subject"/>,
/// <see cref="LegacySceneSelection.GraphicType"/> and
/// <see cref="LegacySceneSelection.Subtype"/>. Stock-master resolution remains read-only
/// and parameterized. The optional hidden <see cref="LegacySceneSelection.DataCode"/> is
/// only an integrity check and is never used as SQL or as a path.
/// </summary>
public sealed class LegacyGridMarketSceneRequestResolver
{
private static readonly string[] StockCodeColumns = ["STOCK_CODE"];
private readonly IDataQueryExecutor _executor;
private readonly IS5025TrustedManualDataSource? _trustedManualSource;
public LegacyGridMarketSceneRequestResolver(
IDataQueryExecutor executor,
IS5025TrustedManualDataSource? trustedManualSource = null)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_trustedManualSource = trustedManualSource;
}
public async Task<LegacyGridMarketSceneLoadRequest> ResolveAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
return entry.CutCode switch
{
"5023" => new LegacyS5023SceneLoadRequest(CreateS5023(entry)),
"5024" => new LegacyS5024SceneLoadRequest(CreateS5024(entry)),
"5025" => new LegacyS5025SceneLoadRequest(CreateS5025(entry)),
"5037" => new LegacyS5037SceneLoadRequest(CreateS5037(entry)),
"5085" => CreateS5085(entry),
"6067" => CreateS6067(entry),
"8003" => new LegacyS8003SceneLoadRequest(
await ResolveS8003Async(entry, cancellationToken).ConfigureAwait(false)),
_ => throw new LegacySceneDataException(
"The selected cut is not supported by the grid-market resolver.")
};
}
/// <summary>
/// Required runtime path for s5025. The trusted source is supplied by native
/// composition/configuration, never by LegacySceneSelection. Reading and validating
/// all five rows completes before any cue reaches DB or COM execution.
/// </summary>
public async Task<S5025SceneData> LoadTrustedS5025Async(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var request = CreateS5025(entry);
var source = _trustedManualSource ?? throw new LegacySceneDataException(
"Trusted manual scene data is not configured.");
try
{
return await new S5025SceneDataLoader(source)
.LoadAsync(request, cancellationToken)
.ConfigureAwait(false);
}
catch (LegacySceneDataException)
{
throw;
}
catch (Exception exception) when (exception is IOException or
UnauthorizedAccessException or
InvalidOperationException)
{
throw new LegacySceneDataException("Trusted manual scene data is unavailable.");
}
}
private static S5023SceneLoadRequest CreateS5023(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireIndexSubject(selection.Subject);
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
RequireNoDataCode(selection.DataCode);
var market = ParseTradingTrendMarket(selection.GraphicType);
RequireSameMarket(selection.GroupCode, market);
return new S5023SceneLoadRequest(market);
}
private static S5024SceneLoadRequest CreateS5024(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireIndexSubject(selection.Subject);
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Bar);
RequireNoDataCode(selection.DataCode);
var market = ParseTradingTrendMarket(selection.GraphicType);
RequireSameMarket(selection.GroupCode, market);
return new S5024SceneLoadRequest(market);
}
private S5025ManualSceneLoadRequest CreateS5025(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireIndexSubject(selection.Subject);
RequireManualNetSellGraphic(selection.GraphicType);
RequireManualNetSellGraphic(selection.Subtype);
RequireNoDataCode(selection.DataCode);
if (_trustedManualSource is null)
{
throw new LegacySceneDataException("Trusted manual scene data is not configured.");
}
var audience = RequireText(selection.GroupCode) switch
{
"개인 순매도 상위(수동)" or "INDIVIDUAL" => S5025ManualAudience.Individual,
"외국인 순매도 상위(수동)" or "FOREIGN" => S5025ManualAudience.Foreign,
"기관 순매도 상위(수동)" or "INSTITUTION" => S5025ManualAudience.Institution,
_ => throw InvalidSelection()
};
return new S5025ManualSceneLoadRequest(audience);
}
private static S5037SceneLoadRequest CreateS5037(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireGraphic(selection.GraphicType, "거래원", "TRADER");
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
RequireOptionalStockCode(selection.DataCode);
return new S5037SceneLoadRequest(
ParseMarket(selection.GroupCode),
RequireSelector(selection.Subject));
}
private static LegacyS5085SceneLoadRequest CreateS5085(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireAllGroup(selection.GroupCode);
RequireIndexSubject(selection.Subject);
RequireGraphic(selection.GraphicType, "프로그램 매매", "PROGRAM_TRADING");
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
RequireNoDataCode(selection.DataCode);
return new LegacyS5085SceneLoadRequest();
}
private static LegacyS6067SceneLoadRequest CreateS6067(LegacyPlaylistEntry entry)
{
var selection = RequireSelection(entry);
RequireAllGroup(selection.GroupCode);
RequireIndexSubject(selection.Subject);
RequireGraphic(selection.GraphicType, "기관 순매수 현황", "INSTITUTION_NET_BUY");
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
RequireNoDataCode(selection.DataCode);
return new LegacyS6067SceneLoadRequest();
}
private async Task<S8003SceneLoadRequest> ResolveS8003Async(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken)
{
var selection = RequireSelection(entry);
var expectedMarket = ParseMarket(selection.GroupCode);
var stockName = RequireSelector(selection.Subject);
RequireGraphic(selection.GraphicType, "호가창", "ORDER_BOOK");
RequireSubtype(selection.Subtype, SceneGraphicSubtype.Table);
var expectedCode = RequireOptionalStockCode(selection.DataCode);
var kospi = await ResolveStockAsync(
LegacyDomesticMarket.Kospi,
stockName,
cancellationToken).ConfigureAwait(false);
var kosdaq = await ResolveStockAsync(
LegacyDomesticMarket.Kosdaq,
stockName,
cancellationToken).ConfigureAwait(false);
// MainForm iterated KOSPI first and KOSDAQ second, so the latter won when
// an identical display name existed in both cached stock-master tables.
var resolved = kosdaq ?? kospi ?? throw new LegacySceneDataException(
"The order-book stock selection was not found.");
if (resolved.Market != expectedMarket ||
(expectedCode.Length > 0 &&
!string.Equals(expectedCode, resolved.StockCode, StringComparison.Ordinal)))
{
throw InvalidSelection();
}
return new S8003SceneLoadRequest(resolved.Market, resolved.StockCode);
}
private async Task<ResolvedStock?> ResolveStockAsync(
LegacyDomesticMarket market,
string stockName,
CancellationToken cancellationToken)
{
var (tableName, table) = market switch
{
LegacyDomesticMarket.Kospi => ("SCENE_RESOLVE_8003_KOSPI", "T_STOCK"),
LegacyDomesticMarket.Kosdaq => ("SCENE_RESOLVE_8003_KOSDAQ", "T_KOSDAQ_STOCK"),
_ => throw InvalidSelection()
};
var spec = LegacyTabularSceneLoaderReader.Spec(
DataSourceKind.Oracle,
$"""
SELECT f_stock_code STOCK_CODE
FROM {table}
WHERE f_mkt_halt = 'N'
AND f_stock_wanname = :stockName
""",
new DataQueryParameter("stockName", stockName, DbType.String));
var result = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
tableName,
spec,
cancellationToken).ConfigureAwait(false);
if (result is null || result.Columns.Count != StockCodeColumns.Length ||
!string.Equals(
result.Columns[0].ColumnName,
StockCodeColumns[0],
StringComparison.OrdinalIgnoreCase) ||
result.Rows.Count > 1)
{
throw new LegacySceneDataException(
"An order-book stock-master lookup returned an invalid result.");
}
if (result.Rows.Count == 0)
{
return null;
}
if (result.Rows[0][0] is null or DBNull)
{
throw new LegacySceneDataException(
"An order-book stock-master lookup returned an invalid result.");
}
var code = RequireStockCode(
Convert.ToString(result.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture));
return new ResolvedStock(market, code);
}
private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
entry.Selection ?? throw new LegacySceneDataException(
"The selected cut requires native lookup information.");
private static LegacyDomesticMarket ParseTradingTrendMarket(string value) =>
RequireText(value) switch
{
"코스피 매매동향" or "KOSPI_TRADING_TREND" => LegacyDomesticMarket.Kospi,
"코스닥 매매동향" or "KOSDAQ_TRADING_TREND" => LegacyDomesticMarket.Kosdaq,
_ => throw InvalidSelection()
};
private static LegacyDomesticMarket ParseMarket(string value) =>
RequireText(value) switch
{
"코스피" or "KOSPI" => LegacyDomesticMarket.Kospi,
"코스닥" or "KOSDAQ" => LegacyDomesticMarket.Kosdaq,
_ => throw InvalidSelection()
};
private static void RequireSameMarket(string value, LegacyDomesticMarket expected)
{
if (ParseMarket(value) != expected)
{
throw InvalidSelection();
}
}
private static void RequireIndexSubject(string value)
{
if (RequireText(value) is not ("지수" or "INDEX"))
{
throw InvalidSelection();
}
}
private static void RequireAllGroup(string value)
{
if (RequireText(value) is not ("전체" or "ALL"))
{
throw InvalidSelection();
}
}
private static void RequireSubtype(string value, SceneGraphicSubtype subtype)
{
var text = RequireText(value);
var valid = subtype switch
{
SceneGraphicSubtype.Table => text is "표그래프" or "TABLE_GRAPH",
SceneGraphicSubtype.Bar => text is "막대그래프" or "BAR_GRAPH",
_ => false
};
if (!valid)
{
throw InvalidSelection();
}
}
private static void RequireManualNetSellGraphic(string value)
{
if (RequireText(value) is not ("순매도 상위" or "MANUAL_NET_SELL"))
{
throw InvalidSelection();
}
}
private static void RequireGraphic(string value, string korean, string stableCode)
{
var text = RequireText(value);
if (!string.Equals(text, korean, StringComparison.Ordinal) &&
!string.Equals(text, stableCode, StringComparison.Ordinal))
{
throw InvalidSelection();
}
}
private static string RequireSelector(string value)
{
var text = RequireText(value);
if (text.Length > 128)
{
throw InvalidSelection();
}
return text;
}
private static string RequireText(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 256 || value.Any(char.IsControl))
{
throw InvalidSelection();
}
return value.Trim();
}
private static void RequireNoDataCode(string? value)
{
if (value is null || value.Length != 0)
{
throw InvalidSelection();
}
}
private static string RequireOptionalStockCode(string? value) =>
value is { Length: 0 } ? string.Empty : RequireStockCode(value);
private static string RequireStockCode(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw InvalidSelection();
}
return value;
}
private static LegacySceneDataException InvalidSelection() =>
new("The selected scene lookup fields are invalid.");
private sealed record ResolvedStock(
LegacyDomesticMarket Market,
string StockCode);
private enum SceneGraphicSubtype
{
Table,
Bar
}
}

View File

@@ -0,0 +1,639 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Closed result for the legacy aliases (8018/8032/5032) that MainForm routed
/// to one of two different scene builders after inspecting the playlist fields.
/// </summary>
public abstract record LegacyTwoColumnSceneLoadRequest(string BuilderKey);
public sealed record LegacyS5032LoadRequest(S5032SceneLoadRequest Request)
: LegacyTwoColumnSceneLoadRequest("s5032");
public sealed record LegacyS8018LoadRequest(S8018SceneLoadRequest Request)
: LegacyTwoColumnSceneLoadRequest("s8018");
/// <summary>
/// Converts the five legacy playlist columns into closed, scene-specific load requests.
/// The resolver mirrors MainForm's conditional s5001/s5032/s8018/s8001 dispatch and uses
/// parameterized, read-only stock-master lookups where MainForm used its cached stock tables.
/// </summary>
public sealed class LegacyParameterizedSceneRequestResolver
{
private readonly IDataQueryExecutor _executor;
private readonly TimeProvider _timeProvider;
public LegacyParameterizedSceneRequestResolver(
IDataQueryExecutor executor,
TimeProvider? timeProvider = null)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public S5001SceneLoadRequest CreateS5001Request(LegacyPlaylistEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.CutCode is not ("5001" or "N5001"))
{
throw new LegacySceneDataException("The selected cut is not an s5001 alias.");
}
var selection = RequireSelection(entry);
var subject = RequireSelector(selection.Subject, "subject");
if (subject == "BDI 지수" || subject == "BALTIC_DRY_INDEX")
{
return new S5001BalticDryIndexLoadRequest();
}
if (TryParseCommodity(subject, out var commodity))
{
return new S5001CommodityLoadRequest(commodity);
}
if (entry.CutCode == "N5001")
{
return CreateS5001DomesticStock(selection, subject);
}
if (subject is "지수" or "INDEX")
{
return new S5001DomesticIndexLoadRequest(
ParseDomesticIndex(selection.GroupCode),
IsExpectedIndex(selection.Subtype)
? S5001IndexMode.Expected
: S5001IndexMode.Current);
}
if (selection.GroupCode is "해외지수" or "FOREIGN_INDEX")
{
return new S5001ForeignIndexLoadRequest(ParseForeignIndex(subject));
}
if (selection.GroupCode is "환율" or "EXCHANGE_RATE")
{
return new S5001ExchangeRateLoadRequest(ParseExchangeRate(subject));
}
if (selection.GroupCode is "해외업종" or "FOREIGN_INDUSTRY")
{
return new S5001ForeignIndustryLoadRequest(subject);
}
if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK")
{
return new S5001ForeignStockLoadRequest(subject);
}
if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) ||
selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal))
{
return new S5001DomesticIndustryLoadRequest(
ParseDomesticMarket(selection.GroupCode, allowNxt: true),
subject);
}
return CreateS5001DomesticStock(selection, subject);
}
public async Task<LegacyTwoColumnSceneLoadRequest> ResolveTwoColumnRequestAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.CutCode is not ("8018" or "8032" or "5032"))
{
throw new LegacySceneDataException("The selected cut is not a two-column alias.");
}
var selection = RequireSelection(entry);
var subject = RequireSelector(selection.Subject, "subject");
// MainForm selects s8018 idx=3 before considering the futures condition.
if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) ||
selection.GroupCode.StartsWith("INDUSTRY_", StringComparison.Ordinal))
{
var industries = SplitPair(subject, '-');
var market = ParseDomesticMarket(selection.GroupCode, allowNxt: false);
return new LegacyS8018LoadRequest(
new S8018DomesticIndustryPairLoadRequest(
market,
industries[0],
industries[1]));
}
// MainForm selects the special s5032 builder whenever the combined subject
// contains the Korean futures label. s5032 then normalizes futures to side two.
if (subject.Contains("선물", StringComparison.Ordinal) ||
subject.Contains("FUTURES", StringComparison.Ordinal))
{
var values = SplitPair(subject, ',');
var first = ParseMarketTarget(values[0]);
var second = ParseMarketTarget(values[1]);
var firstIsFutures = first == LegacyMarketQuoteTarget.Futures;
var secondIsFutures = second == LegacyMarketQuoteTarget.Futures;
if (firstIsFutures == secondIsFutures)
{
throw new LegacySceneDataException(
"The s5032 selection must contain exactly one futures item.");
}
return new LegacyS5032LoadRequest(
new S5032IndexAndFuturesLoadRequest(firstIsFutures ? second : first));
}
var pair = SplitPair(subject, ',');
var expected = IsExpectedPair(selection.Subtype);
var firstIsTarget = TryParseMarketTarget(pair[0], out var firstTarget);
var secondIsTarget = TryParseMarketTarget(pair[1], out var secondTarget);
if (firstIsTarget && secondIsTarget)
{
return new LegacyS8018LoadRequest(
new S8018MarketPairLoadRequest(
expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current,
firstTarget,
secondTarget));
}
if (firstIsTarget != secondIsTarget)
{
var stockIndex = firstIsTarget ? 1 : 0;
var stock = await ResolveCurrentStockAsync(
pair[stockIndex],
allowWorld: false,
cancellationToken).ConfigureAwait(false);
var domesticMarket = ToKrxMarket(stock.Market);
return new LegacyS8018LoadRequest(
new S8018MixedMarketAndStockLoadRequest(
stockIndex == 0 ? S8018PairSide.First : S8018PairSide.Second,
firstIsTarget ? firstTarget : secondTarget,
domesticMarket,
stock.StockName,
expected ? S8018MarketDataMode.Expected : S8018MarketDataMode.Current));
}
if (expected)
{
var first = await ResolveDomesticStockAsync(pair[0], cancellationToken)
.ConfigureAwait(false);
var second = await ResolveDomesticStockAsync(pair[1], cancellationToken)
.ConfigureAwait(false);
return new LegacyS8018LoadRequest(
new S8018ExpectedStockPairLoadRequest(
first.Market,
first.StockName,
second.Market,
second.StockName));
}
var currentFirst = await ResolveCurrentStockAsync(
pair[0],
allowWorld: true,
cancellationToken).ConfigureAwait(false);
var currentSecond = await ResolveCurrentStockAsync(
pair[1],
allowWorld: true,
cancellationToken).ConfigureAwait(false);
return new LegacyS8018LoadRequest(
new S8018CurrentStockPairLoadRequest(
CurrentSession(),
currentFirst,
currentSecond));
}
public S8001SceneLoadRequest CreateS8001Request(LegacyPlaylistEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.CutCode is not ("8001" or "8002"))
{
throw new LegacySceneDataException("The selected cut is not an s8001 alias.");
}
var subject = RequireSelector(RequireSelection(entry).Subject, "subject");
return subject switch
{
"코스피" or "KOSPI" => new S8001SceneLoadRequest(S8001Market.Kospi),
"코스닥" or "KOSDAQ" => new S8001SceneLoadRequest(S8001Market.Kosdaq),
_ => throw new LegacySceneDataException("The s8001 market selection is invalid.")
};
}
private S5001DomesticStockLoadRequest CreateS5001DomesticStock(
LegacySceneSelection selection,
string subject)
{
var market = ParseDomesticMarket(selection.GroupCode, allowNxt: true);
var mode = selection.Subtype switch
{
"예상체결가" or "EXPECTED" or "EXPECTED_OPENING" =>
S5001DomesticStockMode.ExpectedOpening,
"시간외단일가" or "AFTER_HOURS" or "AFTER_HOURS_SINGLE_PRICE" =>
S5001DomesticStockMode.AfterHoursSinglePrice,
_ => S5001DomesticStockMode.Current
};
var halted = mode == S5001DomesticStockMode.Current &&
(selection.Subtype.Contains("거래정지", StringComparison.Ordinal) ||
selection.Subtype == "HALTED");
var nxt = market is LegacyDomesticEquityMarket.NxtKospi or
LegacyDomesticEquityMarket.NxtKosdaq;
var badge = !nxt
? S5001NxtBadge.None
: _timeProvider.GetLocalNow().Hour <= 12
? S5001NxtBadge.PreMarket
: S5001NxtBadge.AfterMarket;
return new S5001DomesticStockLoadRequest(
market,
mode,
badge,
halted ? S5001StockTradingStatus.Halted : S5001StockTradingStatus.Regular,
subject);
}
private async Task<S8018CurrentStockSelection> ResolveCurrentStockAsync(
string rawName,
bool allowWorld,
CancellationToken cancellationToken)
{
var name = RequireSelector(rawName, "stock");
var isNxt = name.Contains("(NXT)", StringComparison.Ordinal);
var selector = isNxt
? RequireSelector(name.Replace("(NXT)", string.Empty, StringComparison.Ordinal), "stock")
: name;
if (isNxt)
{
S8018StockMarket? market = null;
if (await StockExistsAsync(StockMasterKind.NxtKospi, selector, cancellationToken)
.ConfigureAwait(false))
{
market = S8018StockMarket.NxtKospi;
}
// Preserve MainForm's later-table precedence when names overlap.
if (await StockExistsAsync(StockMasterKind.NxtKosdaq, selector, cancellationToken)
.ConfigureAwait(false))
{
market = S8018StockMarket.NxtKosdaq;
}
return market.HasValue
? new S8018CurrentStockSelection(market.Value, selector)
: throw new LegacySceneDataException("The NXT stock selection was not found.");
}
S8018StockMarket? domestic = null;
if (await StockExistsAsync(StockMasterKind.Kospi, selector, cancellationToken)
.ConfigureAwait(false))
{
domestic = S8018StockMarket.Kospi;
}
if (await StockExistsAsync(StockMasterKind.Kosdaq, selector, cancellationToken)
.ConfigureAwait(false))
{
domestic = S8018StockMarket.Kosdaq;
}
if (domestic.HasValue)
{
return new S8018CurrentStockSelection(domestic.Value, selector);
}
if (allowWorld &&
await StockExistsAsync(StockMasterKind.World, selector, cancellationToken)
.ConfigureAwait(false))
{
return new S8018CurrentStockSelection(S8018StockMarket.World, selector);
}
throw new LegacySceneDataException("The stock selection was not found.");
}
private async Task<DomesticStockSelection> ResolveDomesticStockAsync(
string rawName,
CancellationToken cancellationToken)
{
if (rawName.Contains("(NXT)", StringComparison.Ordinal))
{
throw new LegacySceneDataException(
"Expected stock data is unavailable for NXT selections.");
}
var current = await ResolveCurrentStockAsync(
rawName,
allowWorld: false,
cancellationToken).ConfigureAwait(false);
return new DomesticStockSelection(ToKrxMarket(current.Market), current.StockName);
}
private async Task<bool> StockExistsAsync(
StockMasterKind kind,
string stockName,
CancellationToken cancellationToken)
{
var query = StockMasterQuery(kind, stockName);
var table = await _executor.ExecuteAsync(
query.Source,
query.TableName,
query.Spec,
cancellationToken).ConfigureAwait(false);
if (table is null || table.Columns.Count != 1 || table.Rows.Count != 1 ||
!string.Equals(table.Columns[0].ColumnName, "MATCH_COUNT", StringComparison.OrdinalIgnoreCase) ||
table.Rows[0][0] is null or DBNull)
{
throw new LegacySceneDataException("A stock-master lookup returned an invalid schema.");
}
try
{
var count = Convert.ToInt32(table.Rows[0][0], System.Globalization.CultureInfo.InvariantCulture);
return count switch
{
0 => false,
1 => true,
_ => throw new LegacySceneDataException(
"A stock-master lookup returned a non-unique selection.")
};
}
catch (LegacySceneDataException)
{
throw;
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
_ = exception;
throw new LegacySceneDataException(
"A stock-master lookup returned an invalid count.");
}
}
private static ParameterizedSceneQuery StockMasterQuery(
StockMasterKind kind,
string stockName)
{
var parameter = new DataQueryParameter("stockName", stockName, DbType.String);
return kind switch
{
StockMasterKind.Kospi => new ParameterizedSceneQuery(
DataSourceKind.Oracle,
"SCENE_RESOLVE_KOSPI_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_STOCK " +
"WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
[parameter])),
StockMasterKind.Kosdaq => new ParameterizedSceneQuery(
DataSourceKind.Oracle,
"SCENE_RESOLVE_KOSDAQ_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_KOSDAQ_STOCK " +
"WHERE F_MKT_HALT = 'N' AND F_STOCK_WANNAME = :stockName",
[parameter])),
StockMasterKind.NxtKospi => new ParameterizedSceneQuery(
DataSourceKind.MariaDb,
"SCENE_RESOLVE_NXT_KOSPI_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM N_STOCK " +
"WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName",
[parameter])),
StockMasterKind.NxtKosdaq => new ParameterizedSceneQuery(
DataSourceKind.MariaDb,
"SCENE_RESOLVE_NXT_KOSDAQ_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM N_KOSDAQ_STOCK " +
"WHERE F_STOP_GUBUN = 'N' AND F_STOCK_NAME = @stockName",
[parameter])),
StockMasterKind.World => new ParameterizedSceneQuery(
DataSourceKind.Oracle,
"SCENE_RESOLVE_WORLD_STOCK",
new DataQuerySpec(
"SELECT COUNT(*) MATCH_COUNT FROM T_WORLD_IX_EQ_MASTER " +
"WHERE F_KNAM = :stockName",
[parameter])),
_ => throw new LegacySceneDataException("The stock-master lookup is invalid.")
};
}
private S8018SessionPhase CurrentSession()
{
var now = _timeProvider.GetLocalNow();
return now.TimeOfDay > new TimeSpan(15, 30, 0)
? S8018SessionPhase.AfterKrxClose
: S8018SessionPhase.BeforeOrAtKrxClose;
}
private static LegacyDomesticEquityMarket ToKrxMarket(S8018StockMarket market) => market switch
{
S8018StockMarket.Kospi => LegacyDomesticEquityMarket.Kospi,
S8018StockMarket.Kosdaq => LegacyDomesticEquityMarket.Kosdaq,
_ => throw new LegacySceneDataException(
"The selected scene branch supports only KRX stocks.")
};
private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
entry.Selection ?? throw new LegacySceneDataException(
"The selected cut requires native database lookup information.");
private static string RequireSelector(string value, string label)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new LegacySceneDataException($"The scene {label} selection is required.");
}
return value.Trim();
}
private static string[] SplitPair(string value, char separator)
{
var values = value.Split(separator, StringSplitOptions.TrimEntries);
if (values.Length != 2 || values.Any(string.IsNullOrWhiteSpace))
{
throw new LegacySceneDataException("The scene pair selection is invalid.");
}
return values;
}
private static LegacyDomesticEquityMarket ParseDomesticMarket(string value, bool allowNxt)
{
var market = value switch
{
"코스피" or "KOSPI" or "KOSPI_STOCK" => LegacyDomesticEquityMarket.Kospi,
"코스닥" or "KOSDAQ" or "KOSDAQ_STOCK" => LegacyDomesticEquityMarket.Kosdaq,
"코스피_NXT" or "NXT코스피" or "NXT_KOSPI" => LegacyDomesticEquityMarket.NxtKospi,
"코스닥_NXT" or "NXT코스닥" or "NXT_KOSDAQ" => LegacyDomesticEquityMarket.NxtKosdaq,
_ when value.Contains("코스피_NXT", StringComparison.Ordinal) ||
value.Contains("NXT_KOSPI", StringComparison.Ordinal) =>
LegacyDomesticEquityMarket.NxtKospi,
_ when value.Contains("코스닥_NXT", StringComparison.Ordinal) ||
value.Contains("NXT_KOSDAQ", StringComparison.Ordinal) =>
LegacyDomesticEquityMarket.NxtKosdaq,
_ when value.Contains("코스피", StringComparison.Ordinal) ||
value.Contains("KOSPI", StringComparison.Ordinal) =>
LegacyDomesticEquityMarket.Kospi,
_ when value.Contains("코스닥", StringComparison.Ordinal) ||
value.Contains("KOSDAQ", StringComparison.Ordinal) =>
LegacyDomesticEquityMarket.Kosdaq,
_ => throw new LegacySceneDataException("The domestic equity market selection is invalid.")
};
if (!allowNxt && market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq)
{
throw new LegacySceneDataException("The selected scene branch does not support NXT.");
}
return market;
}
private static S5001DomesticIndexTarget ParseDomesticIndex(string value) => value switch
{
"코스피" or "KOSPI" => S5001DomesticIndexTarget.Kospi,
"코스닥" or "KOSDAQ" => S5001DomesticIndexTarget.Kosdaq,
"코스피200" or "코스피 200" or "KOSPI200" => S5001DomesticIndexTarget.Kospi200,
"선물" or "FUTURES" => S5001DomesticIndexTarget.Futures,
"KRX100" or "KRX 100" => S5001DomesticIndexTarget.Krx100,
_ => throw new LegacySceneDataException("The domestic index selection is invalid.")
};
private static bool IsExpectedIndex(string value) => value is
"예상지수" or "EXPECTED" or "EXPECTED_INDEX";
private static bool IsExpectedPair(string value) => value is
"예상체결" or "예상체결가" or "EXPECTED" or "EXPECTED_OPENING";
private static S5001ForeignIndexTarget ParseForeignIndex(string value) => value switch
{
"다우" or "Dow" => S5001ForeignIndexTarget.Dow,
"나스닥" or "Nasdaq" => S5001ForeignIndexTarget.Nasdaq,
"S&P" or "S&P500" or "Sp500" => S5001ForeignIndexTarget.Sp500,
"독일" or "GermanyDax" => S5001ForeignIndexTarget.GermanyDax,
"영국" or "UnitedKingdomFtse" => S5001ForeignIndexTarget.UnitedKingdomFtse,
"프랑스" or "FranceCac" => S5001ForeignIndexTarget.FranceCac,
"니케이" or "Nikkei" => S5001ForeignIndexTarget.Nikkei,
"중국 상해" or "ShanghaiComposite" => S5001ForeignIndexTarget.ShanghaiComposite,
"홍콩 항셍" or "HangSeng" => S5001ForeignIndexTarget.HangSeng,
"대만 가권" or "TaiwanWeighted" => S5001ForeignIndexTarget.TaiwanWeighted,
"싱가포르 지수" or "SingaporeStraitsTimes" => S5001ForeignIndexTarget.SingaporeStraitsTimes,
"태국 지수" or "ThailandSet" => S5001ForeignIndexTarget.ThailandSet,
"필리핀 지수" or "PhilippinesComposite" => S5001ForeignIndexTarget.PhilippinesComposite,
"말레이시아 지수" or "MalaysiaKlse" => S5001ForeignIndexTarget.MalaysiaKlse,
"인도네시아 지수" or "IndonesiaComposite" => S5001ForeignIndexTarget.IndonesiaComposite,
_ => throw new LegacySceneDataException("The foreign index selection is invalid.")
};
private static S5001ExchangeRateTarget ParseExchangeRate(string value) => value switch
{
"원달러" or "WonDollar" => S5001ExchangeRateTarget.WonDollar,
"원엔" or "WonYen" => S5001ExchangeRateTarget.WonYen,
"원위엔" or "원위안" or "WonYuan" => S5001ExchangeRateTarget.WonYuan,
"원유로" or "WonEuro" => S5001ExchangeRateTarget.WonEuro,
_ => throw new LegacySceneDataException("The exchange-rate selection is invalid.")
};
private static LegacyMarketQuoteTarget ParseMarketTarget(string value) =>
TryParseMarketTarget(value, out var target)
? target
: throw new LegacySceneDataException("The market item selection is invalid.");
private static bool TryParseMarketTarget(string value, out LegacyMarketQuoteTarget target)
{
var text = value.Trim();
if (Enum.TryParse<LegacyMarketQuoteTarget>(text, ignoreCase: false, out target) &&
Enum.IsDefined(target))
{
return true;
}
if (text.Contains("코스피", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kospi;
else if (text.Contains("코스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Kosdaq;
else if (text.Contains("선물", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Futures;
else if (text.Contains("KRX100", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Krx100;
else if (text.Contains("원달러", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonDollar;
else if (text.Contains("원엔", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYen;
else if (text.Contains("원위엔", StringComparison.Ordinal) || text.Contains("원위안", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonYuan;
else if (text.Contains("원유로", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.WonEuro;
else if (text.Contains("다우", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Dow;
else if (text.Contains("나스닥", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nasdaq;
else if (text.Contains("S&P", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Sp500;
else if (text.Contains("독일", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.GermanyDax;
else if (text.Contains("영국", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.UnitedKingdomFtse;
else if (text.Contains("프랑스", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.FranceCac;
else if (text.Contains("니케이", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.Nikkei;
else if (text.Contains("중국 상해", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ShanghaiComposite;
else if (text.Contains("홍콩 항셍", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.HangSeng;
else if (text.Contains("대만 가권", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.TaiwanWeighted;
else if (text.Contains("싱가포르 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.SingaporeStraitsTimes;
else if (text.Contains("태국 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.ThailandSet;
else if (text.Contains("필리핀 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.PhilippinesComposite;
else if (text.Contains("말레이시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.MalaysiaKlse;
else if (text.Contains("인도네시아 지수", StringComparison.Ordinal)) target = LegacyMarketQuoteTarget.IndonesiaComposite;
else
{
target = default;
return false;
}
return true;
}
private static bool TryParseCommodity(string value, out S5001CommodityTarget target)
{
if (Enum.TryParse(value, ignoreCase: false, out target) && Enum.IsDefined(target))
{
return true;
}
return CommodityNames.TryGetValue(value, out target);
}
private static readonly IReadOnlyDictionary<string, S5001CommodityTarget> CommodityNames =
new Dictionary<string, S5001CommodityTarget>(StringComparer.Ordinal)
{
["소맥"] = S5001CommodityTarget.FeedWheat,
["옥수수"] = S5001CommodityTarget.Corn,
["밀"] = S5001CommodityTarget.Wheat,
["대두"] = S5001CommodityTarget.Soybean,
["콩"] = S5001CommodityTarget.Bean,
["현미"] = S5001CommodityTarget.BrownRice,
["커피"] = S5001CommodityTarget.Coffee,
["코코아"] = S5001CommodityTarget.Cocoa,
["설탕"] = S5001CommodityTarget.Sugar,
["원면"] = S5001CommodityTarget.RawCotton,
["목화(면화)"] = S5001CommodityTarget.Cotton,
["생우(소)"] = S5001CommodityTarget.LiveCattle,
["비육우"] = S5001CommodityTarget.FeederCattle,
["돈육(돼지)"] = S5001CommodityTarget.LeanHogs,
["구리"] = S5001CommodityTarget.Copper,
["철광석"] = S5001CommodityTarget.IronOre,
["니켈"] = S5001CommodityTarget.Nickel,
["천연가스"] = S5001CommodityTarget.NaturalGas,
["백금"] = S5001CommodityTarget.Platinum,
["팔라듐"] = S5001CommodityTarget.Palladium,
["납"] = S5001CommodityTarget.Lead,
["아연"] = S5001CommodityTarget.Zinc,
["주석"] = S5001CommodityTarget.Tin,
["국제 금"] = S5001CommodityTarget.InternationalGold,
["국제 은"] = S5001CommodityTarget.InternationalSilver,
["국내 금"] = S5001CommodityTarget.DomesticGold,
["국내 은"] = S5001CommodityTarget.DomesticSilver
};
private sealed record DomesticStockSelection(
LegacyDomesticEquityMarket Market,
string StockName);
private enum StockMasterKind
{
Kospi,
Kosdaq,
NxtKospi,
NxtKosdaq,
World
}
}

View File

@@ -0,0 +1,572 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Native lookup fields corresponding to the legacy playlist's code, subject,
/// graphic type, subtype and hidden data-code columns. They may select parameterized
/// DB data but never carry K3D method names, object names, mutations or asset paths.
/// </summary>
public sealed record LegacySceneSelection(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
public sealed record LegacyPlaylistEntry(
string EntryId,
string CutCode,
bool IsEnabled = true,
int? FadeDuration = null,
LegacySceneSelection? Selection = null);
public sealed record LegacySceneCuePage(
PlayoutCue Cue,
int ItemCount,
ScenePageSize? PageSize = null,
string? BuilderKey = null,
IReadOnlyList<LegacyScenePreviewField>? PreviewFields = null);
/// <summary>
/// Native-only data boundary. Implementations query DB data and invoke a registered typed
/// scene builder; Web input can select a catalog code but cannot supply mutations or paths.
/// </summary>
public interface ILegacySceneCueProvider
{
Task<LegacySceneCuePage> CreatePageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default);
}
public enum LegacyWorkflowNextKind
{
None,
PageNext,
PlaylistNext,
EndOfPlaylist
}
public sealed record LegacyPlayoutWorkflowState(
int CurrentCueIndexZeroBased,
string? PreparedCutCode,
string? OnAirCutCode,
int PageIndexZeroBased,
int PageCount,
int PageSize,
int ItemCount,
bool IsLastPage,
LegacyWorkflowNextKind NextKind,
string? CurrentEntryId,
string? BuilderKey,
int CurrentPageItemCount,
IReadOnlyList<LegacyScenePreviewField> PreviewFields);
/// <summary>
/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine.
/// It is deliberately unaware of COM and serializes DB-to-cue work with engine calls.
/// </summary>
public sealed class LegacyPlayoutWorkflow
{
private const int MaximumPlaylistItems = 1_000;
private readonly IPlayoutEngine _engine;
private readonly ILegacySceneCueProvider _cueProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
private ActivePage? _prepared;
private ActivePage? _onAir;
private int _engineClearRequested;
public LegacyPlayoutWorkflow(
IPlayoutEngine engine,
ILegacySceneCueProvider cueProvider)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider));
State = EmptyState;
}
public LegacyPlayoutWorkflowState State { get; private set; }
/// <summary>
/// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native
/// playlist state. It never dispatches an engine command. If a command currently
/// owns the workflow gate, reconciliation is applied before that command releases it.
/// </summary>
public void ObserveEngineStatus(PlayoutStatus status)
{
ArgumentNullException.ThrowIfNull(status);
if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
!string.IsNullOrWhiteSpace(status.OnAirSceneName))
{
return;
}
Interlocked.Exchange(ref _engineClearRequested, 1);
if (!_gate.Wait(0))
{
return;
}
try
{
ApplyRequestedEngineClear();
}
finally
{
_gate.Release();
}
}
public async Task<PlayoutResult> PrepareAsync(
IReadOnlyList<LegacyPlaylistEntry> playlist,
int cueIndexZeroBased,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// MainForm's PREPARE button is a mode toggle. When PREPARE/PROGRAM is
// already active, a second press follows btnTakeout_Click and StopAll
// instead of replacing the current scene with another prepare.
if (_prepared is not null || _onAir is not null)
{
var takeOut = await _engine.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
if (takeOut.IsSuccess)
{
_prepared = null;
_onAir = null;
_playlist = [];
State = EmptyState;
}
return takeOut;
}
var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
if (selectedIndex is null)
{
return Rejected(
PlayoutOperation.Prepare,
"선택한 위치 이후에 활성화된 플레이리스트 항목이 없습니다.");
}
var page = await CreatePageAsync(
snapshot,
selectedIndex.Value,
pageIndexZeroBased: 0,
cancellationToken).ConfigureAwait(false);
var result = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
.ConfigureAwait(false);
if (result.IsSuccess)
{
_playlist = snapshot;
_prepared = page;
PublishState();
}
return result;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
public async Task<PlayoutResult> TakeInAsync(
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_prepared is null)
{
return Rejected(PlayoutOperation.TakeIn, "PREPARE가 완료된 장면이 없습니다.");
}
// MainForm's TAKE IN path resets m_Super and runs ONAirMode again before
// Play(10). Recreate the selected page and prepare the fresh database
// snapshot; never play when that prepare did not succeed.
var refreshed = await CreatePageAsync(
_playlist,
_prepared.CueIndexZeroBased,
_prepared.PageIndexZeroBased,
cancellationToken).ConfigureAwait(false);
RequireSameScene(_prepared, refreshed, "TAKE IN refresh");
var prepare = await _engine.PrepareAsync(
refreshed.Page.Cue,
cancellationToken).ConfigureAwait(false);
if (!prepare.IsSuccess)
{
return prepare;
}
// The engine now owns the freshly prepared scene even if Play fails.
_prepared = refreshed;
PublishState();
var result = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAir = refreshed;
_prepared = null;
PublishState();
}
return result;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
public async Task<PlayoutResult> NextAsync(
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_onAir is null)
{
return Rejected(PlayoutOperation.Next, "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.");
}
if (_onAir.Plan is { } currentPlan &&
_onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages)
{
var nextPage = await CreatePageAsync(
_playlist,
_onAir.CueIndexZeroBased,
_onAir.PageIndexZeroBased + 1,
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
nextPage.Page.Cue.SceneName,
_onAir.Page.Cue.SceneName,
StringComparison.OrdinalIgnoreCase))
{
throw new LegacySceneDataException(
"A page update must target the scene already on air.");
}
// btnNext_Click calls Next_Scene(0). For all 5/6/12-row branches
// the idx == 0 path loads and prepares a fresh scene before Play.
var result = await _engine.NextAsync(
nextPage.Page.Cue,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAir = nextPage;
PublishState();
}
return result;
}
var targetIndex = FindNextEnabledCue(_onAir.CueIndexZeroBased + 1);
if (targetIndex is null)
{
PublishState(LegacyWorkflowNextKind.EndOfPlaylist);
return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다.");
}
var nextCue = await CreatePageAsync(
_playlist,
targetIndex.Value,
pageIndexZeroBased: 0,
cancellationToken).ConfigureAwait(false);
var nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken)
.ConfigureAwait(false);
if (nextResult.IsSuccess)
{
_onAir = nextCue;
_prepared = null;
PublishState();
}
return nextResult;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
/// <summary>
/// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged
/// entry and current page are queried again and updated through
/// IPlayoutEngine.UpdateOnAirAsync, whose vendor implementation uses
/// GetPlayingScene. Timer start/stop policy remains an application concern.
/// </summary>
public async Task<PlayoutResult> RefreshOnAirAsync(
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_onAir is null)
{
return Rejected(
PlayoutOperation.UpdateOnAir,
"A scene must be on air before it can be refreshed.");
}
var refreshed = await CreatePageAsync(
_playlist,
_onAir.CueIndexZeroBased,
_onAir.PageIndexZeroBased,
cancellationToken).ConfigureAwait(false);
RequireSameScene(_onAir, refreshed, "On-air refresh");
var result = await _engine.UpdateOnAirAsync(
refreshed.Page.Cue,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAir = refreshed;
PublishState();
}
return result;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
public async Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var result = await _engine.TakeOutAsync(scope, cancellationToken)
.ConfigureAwait(false);
if (result.IsSuccess)
{
_prepared = null;
_onAir = null;
_playlist = [];
State = EmptyState;
}
return result;
}
finally
{
ApplyRequestedEngineClear();
_gate.Release();
}
}
private async Task<ActivePage> CreatePageAsync(
IReadOnlyList<LegacyPlaylistEntry> playlist,
int cueIndexZeroBased,
int pageIndexZeroBased,
CancellationToken cancellationToken)
{
var entry = playlist[cueIndexZeroBased];
var page = await _cueProvider.CreatePageAsync(
entry,
pageIndexZeroBased,
cancellationToken).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(page.Cue);
ScenePagingPlan? plan = null;
if (page.PageSize is { } pageSize)
{
var planResult = ScenePaging.CreatePlan(page.ItemCount, pageSize);
if (!planResult.IsValid || planResult.Plan is null)
{
throw new LegacySceneDataException("The scene provider returned invalid paging data.");
}
var window = ScenePaging.GetWindow(
page.ItemCount,
pageSize,
pageIndexZeroBased + 1);
if (!window.IsValid || window.Window is null)
{
throw new LegacySceneDataException("The requested scene page is outside the available range.");
}
plan = planResult.Plan;
}
else if (pageIndexZeroBased != 0 || page.ItemCount < 0)
{
throw new LegacySceneDataException("A non-paged scene returned invalid page data.");
}
return new ActivePage(cueIndexZeroBased, pageIndexZeroBased, page, plan);
}
private static IReadOnlyList<LegacyPlaylistEntry> ValidatePlaylist(
IReadOnlyList<LegacyPlaylistEntry>? playlist,
int cueIndexZeroBased)
{
if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems ||
cueIndexZeroBased < 0 || cueIndexZeroBased >= playlist.Count)
{
throw new ArgumentException("The playlist selection is invalid.", nameof(playlist));
}
var entries = playlist.ToArray();
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entries)
{
if (entry is null || !IsSafeToken(entry.EntryId) || !IsSafeToken(entry.CutCode) ||
!ids.Add(entry.EntryId) ||
entry.FadeDuration is < 0 or > 60 ||
!IsSafeSelection(entry.Selection))
{
throw new ArgumentException("The playlist contains an invalid entry.", nameof(playlist));
}
}
return Array.AsReadOnly(entries);
}
private int? FindNextEnabledCue(int startIndex)
=> FindNextEnabledCue(_playlist, startIndex);
private static int? FindNextEnabledCue(
IReadOnlyList<LegacyPlaylistEntry> playlist,
int startIndex)
{
for (var index = startIndex; index < playlist.Count; index++)
{
if (playlist[index].IsEnabled)
{
return index;
}
}
return null;
}
private void PublishState(LegacyWorkflowNextKind? forcedNextKind = null)
{
var active = _onAir ?? _prepared;
if (active is null)
{
State = EmptyState;
return;
}
var pageCount = active.Plan?.TotalPages ?? 1;
var pageSize = active.Page.PageSize is { } size ? (int)size : 0;
var isLastPage = active.PageIndexZeroBased + 1 >= pageCount;
var nextKind = forcedNextKind ?? (!isLastPage
? LegacyWorkflowNextKind.PageNext
: FindNextEnabledCue(active.CueIndexZeroBased + 1).HasValue
? LegacyWorkflowNextKind.PlaylistNext
: LegacyWorkflowNextKind.EndOfPlaylist);
State = new LegacyPlayoutWorkflowState(
active.CueIndexZeroBased,
_prepared?.Page.Cue.SceneName,
_onAir?.Page.Cue.SceneName,
active.PageIndexZeroBased,
pageCount,
pageSize,
active.Page.ItemCount,
isLastPage,
nextKind,
_playlist[active.CueIndexZeroBased].EntryId,
active.Page.BuilderKey,
active.Plan is null
? active.Page.ItemCount
: Math.Min(
pageSize,
Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))),
active.Page.PreviewFields ?? Array.Empty<LegacyScenePreviewField>());
}
private static bool IsSafeToken(string? value) =>
!string.IsNullOrWhiteSpace(value) &&
value.Length <= 128 &&
value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
private static bool IsSafeSelection(LegacySceneSelection? selection)
{
if (selection is null)
{
return true;
}
return IsSafeLookupText(selection.GroupCode) &&
IsSafeLookupText(selection.Subject) &&
IsSafeLookupText(selection.GraphicType) &&
IsSafeLookupText(selection.Subtype) &&
IsSafeLookupText(selection.DataCode);
}
private static bool IsSafeLookupText(string? value) =>
value is not null &&
value.Length <= 256 &&
!value.Any(char.IsControl);
private static void RequireSameScene(
ActivePage current,
ActivePage refreshed,
string operation)
{
if (!string.Equals(
refreshed.Page.Cue.SceneName,
current.Page.Cue.SceneName,
StringComparison.OrdinalIgnoreCase))
{
throw new LegacySceneDataException(
$"{operation} must target the scene that is already selected.");
}
}
private static PlayoutResult Rejected(PlayoutOperation operation, string message) =>
new(operation, PlayoutResultCode.Rejected, false, message, DateTimeOffset.UtcNow);
private void ApplyRequestedEngineClear()
{
if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
{
return;
}
_prepared = null;
_onAir = null;
_playlist = [];
State = EmptyState;
}
private static readonly LegacyPlayoutWorkflowState EmptyState = new(
-1,
null,
null,
0,
0,
0,
0,
true,
LegacyWorkflowNextKind.None,
null,
null,
0,
Array.Empty<LegacyScenePreviewField>());
private sealed record ActivePage(
int CueIndexZeroBased,
int PageIndexZeroBased,
LegacySceneCuePage Page,
ScenePagingPlan? Plan);
}

View File

@@ -0,0 +1,130 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Typed form of the direction strings used by the legacy scene builders.
/// </summary>
public enum ScenePriceDirection
{
Unknown,
LimitUp,
Up,
Flat,
Down,
LimitDown
}
/// <summary>
/// Reproduces MainForm.setObjectStockChange without exposing a K3D object.
/// The returned sequence intentionally follows the original call order.
/// </summary>
public static class LegacyPriceDirectionMutations
{
private static readonly string[] DirectionObjects =
["upup", "up", "flat", "down", "downdown"];
private static readonly string[] VariantObjects =
["bg", "changePrice", "wonText", "rate", "percent"];
public static ScenePriceDirection Parse(string? value) => value?.Trim() switch
{
"상한" => ScenePriceDirection.LimitUp,
"상승" or "+" => ScenePriceDirection.Up,
null or "" or "보합" => ScenePriceDirection.Flat,
"하락" or "-" => ScenePriceDirection.Down,
"하한" => ScenePriceDirection.LimitDown,
_ => ScenePriceDirection.Unknown
};
public static IReadOnlyList<PlayoutMutation> Build(
ScenePriceDirection direction,
int legacyVariantIndex)
{
if (legacyVariantIndex is < 0 or > 3)
{
throw new ArgumentOutOfRangeException(
nameof(legacyVariantIndex),
"Legacy direction variant must be between zero and three.");
}
var suffix = legacyVariantIndex is 1 or 2
? legacyVariantIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)
: string.Empty;
var mutations = new List<PlayoutMutation>(32);
foreach (var objectName in DirectionObjects)
{
mutations.Add(new PlayoutSetVisible(objectName + suffix, false));
}
var activeDirectionObject = direction switch
{
ScenePriceDirection.LimitUp => "upup",
ScenePriceDirection.Up => "up",
ScenePriceDirection.Flat => "flat",
ScenePriceDirection.Down => "down",
ScenePriceDirection.LimitDown => "downdown",
_ => null
};
if (activeDirectionObject is null)
{
return mutations;
}
mutations.Add(new PlayoutSetVisible(activeDirectionObject + suffix, true));
var selectedVariant = direction switch
{
ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1,
ScenePriceDirection.Flat => 2,
ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3,
_ => throw new InvalidOperationException("A known direction was expected.")
};
for (var variant = 1; variant <= 3; variant++)
{
foreach (var objectName in VariantObjects)
{
mutations.Add(new PlayoutSetVisible(
$"{objectName}{variant}_{suffix}",
variant == selectedVariant));
}
}
if (legacyVariantIndex != 0)
{
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"unit{variant}_{suffix}",
variant == selectedVariant));
}
}
// The original helper only changes price variants for the ordinary
// up/flat/down branches when cnt == 3.
if (legacyVariantIndex == 3 &&
direction is ScenePriceDirection.Up or ScenePriceDirection.Flat or ScenePriceDirection.Down)
{
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"price{variant}_{suffix}",
variant == selectedVariant));
}
}
var pattern = selectedVariant switch
{
1 => @"Images\그림_빨강.png",
2 => @"Images\그림_검정.png",
3 => @"Images\그림_파랑.png",
_ => throw new InvalidOperationException("A known visual variant was expected.")
};
mutations.Add(new PlayoutSetAssetValue("pattern" + suffix, pattern));
return mutations;
}
}

View File

@@ -0,0 +1,298 @@
#nullable enable
using System.Collections.ObjectModel;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Fixed metadata for the 35 builders found in the read-only MBN_STOCK_N Scene folder.
/// Runtime implementations and their concrete DTO contracts are validated one-to-one by
/// <see cref="LegacySceneMutationBuilderRegistry"/>.
/// </summary>
public static class LegacySceneCatalog
{
public const int ExpectedDefinitionCount = 35;
private const LegacySceneMutationCapability Value =
LegacySceneMutationCapability.Value;
private const LegacySceneMutationCapability Asset =
LegacySceneMutationCapability.AssetValue;
private const LegacySceneMutationCapability Visible =
LegacySceneMutationCapability.Visibility;
private const LegacySceneMutationCapability Color =
LegacySceneMutationCapability.FaceColor;
private const LegacySceneMutationCapability Position =
LegacySceneMutationCapability.Position;
private const LegacySceneMutationCapability PositionKey =
LegacySceneMutationCapability.PositionKey;
private const LegacySceneMutationCapability Scale =
LegacySceneMutationCapability.Scale;
private const LegacySceneMutationCapability Crop =
LegacySceneMutationCapability.Crop;
private const LegacySceneMutationCapability CircleAngle =
LegacySceneMutationCapability.CircleAngleKey;
private const LegacySceneMutationCapability Path =
LegacySceneMutationCapability.PathPoints;
private const LegacySceneMutationCapability PathShape =
LegacySceneMutationCapability.PathShapePoints;
private const LegacySceneMutationCapability BackgroundVideo =
LegacySceneMutationCapability.BackgroundVideo;
private static readonly ReadOnlyCollection<LegacySceneDefinition> RegisteredDefinitions =
Array.AsReadOnly(CreateDefinitions());
private static readonly IReadOnlyDictionary<string, LegacySceneDefinition> BySceneCode =
CreateSceneCodeIndex(RegisteredDefinitions);
private static readonly IReadOnlyDictionary<string, IReadOnlyList<LegacySceneDefinition>>
ByCutAlias = CreateCutAliasIndex(RegisteredDefinitions);
static LegacySceneCatalog()
{
ValidateInvariants(RegisteredDefinitions);
}
public static IReadOnlyList<LegacySceneDefinition> Definitions => RegisteredDefinitions;
/// <summary>
/// Finds a definition by its builder-derived numeric scene code.
/// </summary>
public static LegacySceneDefinition? FindBySceneCode(string? sceneCode)
{
if (string.IsNullOrWhiteSpace(sceneCode))
{
return null;
}
return BySceneCode.TryGetValue(sceneCode, out var definition)
? definition
: null;
}
/// <summary>
/// Returns every builder eligible for an alias. The 8018/8032/5032 aliases intentionally
/// return both conditional builders; the caller must use typed playlist context to choose.
/// </summary>
public static IReadOnlyList<LegacySceneDefinition> FindByCutAlias(string? cutAlias)
{
if (string.IsNullOrWhiteSpace(cutAlias))
{
return Array.Empty<LegacySceneDefinition>();
}
return ByCutAlias.TryGetValue(cutAlias, out var definitions)
? definitions
: Array.Empty<LegacySceneDefinition>();
}
private static LegacySceneDefinition[] CreateDefinitions() =>
[
Define<S5001SceneDtoMarker>("s5001", "5001", ["5001", "N5001"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
Define<S5006SceneDtoMarker>("s5006", "5006", ["5006"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | BackgroundVideo),
Define<S5011SceneDtoMarker>("s5011", "5011", ["5011"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color),
Define<S5016SceneDtoMarker>("s5016", "5016", ["5016"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
Define<S50160SceneDtoMarker>("s50160", "50160", ["50160"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
Define<S5023SceneDtoMarker>("s5023", "5023", ["5023"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
Define<S5024SceneDtoMarker>("s5024", "5024", ["5024"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | Scale),
Define<S5025SceneDtoMarker>("s5025", "5025", ["5025"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value),
Define<S5026SceneDtoMarker>("s5026", "5026", ["5026"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Crop),
Define<S5029SceneDtoMarker>("s5029", "5029", ["5029"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
Define<S5032SceneDtoMarker>("s5032", "5032", ["8018", "8032", "5032"],
LegacyScenePageSize.None,
LegacySceneReachability.ConditionalMainFormDispatch,
Value | Visible | Asset),
Define<S5037SceneDtoMarker>("s5037", "5037", ["5037"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
Define<S5074SceneDtoMarker>("s5074", "5074", ["5074"],
LegacyScenePageSize.Five, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
Define<S5076SceneDtoMarker>("s5076", "5076", ["5076"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | CircleAngle),
Define<S5077SceneDtoMarker>("s5077", "5077", ["5077"],
LegacyScenePageSize.Six, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
Define<S5078SceneDtoMarker>("s5078", "5078", ["5078"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Scale),
Define<S5079SceneDtoMarker>("s5079", "5079", ["5079"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Path),
Define<S5080SceneDtoMarker>("s5080", "5080", ["5080"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | PositionKey | Scale),
Define<S5081SceneDtoMarker>("s5081", "5081", ["5081"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | PositionKey | Scale),
Define<S5082SceneDtoMarker>("s5082", "5082", ["5082"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible),
Define<S5083SceneDtoMarker>("s5083", "5083", ["5083"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Position | Path),
Define<S5084SceneDtoMarker>("s5084", "5084", ["5084"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Position | Path),
Define<S5085SceneDtoMarker>("s5085", "5085", ["5085"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
Define<S5086SceneDtoMarker>("s5086", "5086", ["5086"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
Define<S50860SceneDtoMarker>("s50860", "50860", ["50860"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Position | PathShape),
Define<S5087SceneDtoMarker>("s5087", "5087", ["5087"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Position | PathShape),
Define<S5088SceneDtoMarker>("s5088", "5088", ["5088"],
LegacyScenePageSize.Twelve, LegacySceneReachability.MainFormDispatch,
Value | Visible | Asset),
Define<S6001SceneDtoMarker>("s6001", "6001", ["6001"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Asset | Visible | Color),
Define<S6067SceneDtoMarker>("s6067", "6067", ["6067"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
Define<S8001SceneDtoMarker>("s8001", "8001", ["8001", "8002"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Color),
Define<S8003SceneDtoMarker>("s8003", "8003", ["8003"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Scale),
Define<S8010SceneDtoMarker>(
"s8010",
"8010",
["8035", "8061", "8040", "8046", "8051", "8056"],
LegacyScenePageSize.None,
LegacySceneReachability.MainFormDispatch,
Value | Visible | Color | Position | Crop | Path),
Define<S8018SceneDtoMarker>("s8018", "8018", ["8018", "8032", "5032"],
LegacyScenePageSize.None,
LegacySceneReachability.ConditionalMainFormDispatch,
Value | Visible | Asset),
Define<S8067SceneDtoMarker>("s8067", "8067", ["8067", "5068", "5070", "5072"],
LegacyScenePageSize.None, LegacySceneReachability.MainFormDispatch,
Value | Visible | Color),
Define<S8086SceneDtoMarker>("s8086", "8086", [],
LegacyScenePageSize.None, LegacySceneReachability.NoMainFormDispatch,
Value | Color)
];
private static LegacySceneDefinition Define<TDtoMarker>(
string builderKey,
string sceneCode,
IEnumerable<string> cutAliases,
LegacyScenePageSize pageSize,
LegacySceneReachability reachability,
LegacySceneMutationCapability mutationCapabilities)
where TDtoMarker : ILegacySceneDtoMarker =>
new(
builderKey,
sceneCode,
cutAliases,
pageSize,
reachability,
typeof(TDtoMarker),
builderKey,
mutationCapabilities);
private static IReadOnlyDictionary<string, LegacySceneDefinition> CreateSceneCodeIndex(
IEnumerable<LegacySceneDefinition> definitions) =>
new ReadOnlyDictionary<string, LegacySceneDefinition>(
definitions.ToDictionary(
definition => definition.SceneCode,
StringComparer.Ordinal));
private static IReadOnlyDictionary<string, IReadOnlyList<LegacySceneDefinition>>
CreateCutAliasIndex(IEnumerable<LegacySceneDefinition> definitions)
{
var mutable = new Dictionary<string, List<LegacySceneDefinition>>(
StringComparer.Ordinal);
foreach (var definition in definitions)
{
foreach (var alias in definition.CutAliases)
{
if (!mutable.TryGetValue(alias, out var matches))
{
matches = [];
mutable.Add(alias, matches);
}
matches.Add(definition);
}
}
var readOnly = mutable.ToDictionary(
pair => pair.Key,
pair => (IReadOnlyList<LegacySceneDefinition>)Array.AsReadOnly(pair.Value.ToArray()),
StringComparer.Ordinal);
return new ReadOnlyDictionary<string, IReadOnlyList<LegacySceneDefinition>>(readOnly);
}
private static void ValidateInvariants(
IReadOnlyCollection<LegacySceneDefinition> definitions)
{
if (definitions.Count != ExpectedDefinitionCount)
{
throw new InvalidOperationException(
$"The legacy scene catalog must contain exactly {ExpectedDefinitionCount} definitions.");
}
RequireUnique(definitions, definition => definition.BuilderKey, "builder key");
RequireUnique(definitions, definition => definition.SceneCode, "scene code");
RequireUnique(definitions, definition => definition.SourceHashKey, "source hash key");
RequireUnique(definitions, definition => definition.DtoTypeMarker.FullName!, "DTO marker");
foreach (var aliasGroup in definitions
.SelectMany(definition => definition.CutAliases.Select(alias =>
(Alias: alias, Definition: definition)))
.GroupBy(pair => pair.Alias, StringComparer.Ordinal)
.Where(group => group.Count() > 1))
{
if (aliasGroup.Any(pair =>
pair.Definition.Reachability !=
LegacySceneReachability.ConditionalMainFormDispatch))
{
throw new InvalidOperationException(
$"Shared cut alias '{aliasGroup.Key}' must map only to conditional builders.");
}
}
}
private static void RequireUnique(
IEnumerable<LegacySceneDefinition> definitions,
Func<LegacySceneDefinition, string> selector,
string label)
{
var duplicate = definitions
.GroupBy(selector, StringComparer.Ordinal)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
{
throw new InvalidOperationException(
$"Duplicate {label} '{duplicate.Key}' in the legacy scene catalog.");
}
}
}

View File

@@ -0,0 +1,107 @@
#nullable enable
using System.Collections.ObjectModel;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public delegate Task<LegacySceneDataPage> LegacySceneDataLoaderDelegate(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken);
/// <summary>
/// Native-only route from one or more closed cut aliases to a parameterized DTO loader.
/// The delegate is composed in managed code; it is never selected by a Web-supplied type,
/// method name, object name or file path.
/// </summary>
public sealed class LegacySceneDataSourceRoute
{
public LegacySceneDataSourceRoute(
IEnumerable<string> cutCodes,
LegacySceneDataLoaderDelegate loader)
{
ArgumentNullException.ThrowIfNull(cutCodes);
Loader = loader ?? throw new ArgumentNullException(nameof(loader));
var codes = cutCodes.Select(ValidateCutCode).ToArray();
if (codes.Length == 0 ||
codes.Distinct(StringComparer.Ordinal).Count() != codes.Length)
{
throw new ArgumentException("A scene-data route requires unique cut codes.", nameof(cutCodes));
}
CutCodes = Array.AsReadOnly(codes);
}
public IReadOnlyList<string> CutCodes { get; }
internal LegacySceneDataLoaderDelegate Loader { get; }
private static string ValidateCutCode(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 64 ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw new ArgumentException("A scene-data route contains an invalid cut code.");
}
return value;
}
}
/// <summary>
/// Immutable dispatcher used by the app composition root to join scene-specific DB loaders.
/// Unknown cuts fail closed before any query is issued.
/// </summary>
public sealed class LegacySceneDataSourceRouter : ILegacySceneDataSource
{
private readonly IReadOnlyDictionary<string, LegacySceneDataLoaderDelegate> _loaders;
public LegacySceneDataSourceRouter(IEnumerable<LegacySceneDataSourceRoute> routes)
{
ArgumentNullException.ThrowIfNull(routes);
var loaders = new Dictionary<string, LegacySceneDataLoaderDelegate>(StringComparer.Ordinal);
foreach (var route in routes)
{
ArgumentNullException.ThrowIfNull(route);
foreach (var code in route.CutCodes)
{
if (!loaders.TryAdd(code, route.Loader))
{
throw new ArgumentException(
"A cut code can have only one scene-data route.",
nameof(routes));
}
}
}
if (loaders.Count == 0)
{
throw new ArgumentException("At least one scene-data route is required.", nameof(routes));
}
_loaders = new ReadOnlyDictionary<string, LegacySceneDataLoaderDelegate>(loaders);
CutCodes = Array.AsReadOnly(loaders.Keys.OrderBy(code => code, StringComparer.Ordinal).ToArray());
}
public IReadOnlyList<string> CutCodes { get; }
public Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (pageIndexZeroBased < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased));
}
if (!_loaders.TryGetValue(entry.CutCode, out var loader))
{
throw new LegacySceneDataException(
"The selected cut does not have a registered scene-data loader.");
}
return loader(entry, pageIndexZeroBased, cancellationToken);
}
}

View File

@@ -0,0 +1,237 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Page sizes represented by the legacy scene catalog. None is used by non-paged scenes.
/// </summary>
public enum LegacyScenePageSize
{
None = 0,
Five = 5,
Six = 6,
Twelve = 12
}
/// <summary>
/// Describes whether the legacy MainForm can construct a builder.
/// </summary>
public enum LegacySceneReachability
{
MainFormDispatch,
ConditionalMainFormDispatch,
NoMainFormDispatch
}
/// <summary>
/// Closed metadata describing the K3D mutation families observed in a legacy builder.
/// It deliberately contains no object names, method names, values, or paths.
/// </summary>
[Flags]
public enum LegacySceneMutationCapability
{
None = 0,
Value = 1 << 0,
AssetValue = 1 << 1,
Visibility = 1 << 2,
FaceColor = 1 << 3,
Position = 1 << 4,
PositionKey = 1 << 5,
Scale = 1 << 6,
Crop = 1 << 7,
CircleAngleKey = 1 << 8,
PathPoints = 1 << 9,
PathShapePoints = 1 << 10,
BackgroundVideo = 1 << 11
}
/// <summary>
/// Catalog-only marker that keeps every legacy source slot unique. Concrete runtime DTOs
/// implement ILegacySceneData and are exposed by LegacySceneMutationBuilderRegistry.
/// </summary>
public interface ILegacySceneDtoMarker
{
}
/// <summary>
/// Immutable metadata for one of the 35 legacy scene builders.
/// </summary>
public sealed class LegacySceneDefinition
{
private const LegacySceneMutationCapability AllKnownCapabilities =
LegacySceneMutationCapability.Value |
LegacySceneMutationCapability.AssetValue |
LegacySceneMutationCapability.Visibility |
LegacySceneMutationCapability.FaceColor |
LegacySceneMutationCapability.Position |
LegacySceneMutationCapability.PositionKey |
LegacySceneMutationCapability.Scale |
LegacySceneMutationCapability.Crop |
LegacySceneMutationCapability.CircleAngleKey |
LegacySceneMutationCapability.PathPoints |
LegacySceneMutationCapability.PathShapePoints |
LegacySceneMutationCapability.BackgroundVideo;
internal LegacySceneDefinition(
string builderKey,
string sceneCode,
IEnumerable<string> cutAliases,
LegacyScenePageSize pageSize,
LegacySceneReachability reachability,
Type dtoTypeMarker,
string sourceHashKey,
LegacySceneMutationCapability mutationCapabilities)
{
BuilderKey = RequireBuilderKey(builderKey);
SceneCode = RequireDigits(sceneCode, nameof(sceneCode));
PageSize = RequireDefined(pageSize, nameof(pageSize));
Reachability = RequireDefined(reachability, nameof(reachability));
DtoTypeMarker = RequireDtoType(dtoTypeMarker);
SourceHashKey = RequireSourceHashKey(sourceHashKey);
MutationCapabilities = RequireCapabilities(mutationCapabilities);
ArgumentNullException.ThrowIfNull(cutAliases);
var aliases = cutAliases
.Select(alias => RequireAlias(alias))
.ToArray();
if (aliases.Distinct(StringComparer.Ordinal).Count() != aliases.Length)
{
throw new ArgumentException(
"Cut aliases must be unique within a scene definition.",
nameof(cutAliases));
}
if (reachability == LegacySceneReachability.NoMainFormDispatch)
{
if (aliases.Length != 0)
{
throw new ArgumentException(
"A scene with no MainForm dispatch cannot declare an active cut alias.",
nameof(cutAliases));
}
}
else if (aliases.Length == 0)
{
throw new ArgumentException(
"A reachable scene requires at least one cut alias.",
nameof(cutAliases));
}
CutAliases = Array.AsReadOnly(aliases);
}
public string BuilderKey { get; }
public string SceneCode { get; }
public IReadOnlyList<string> CutAliases { get; }
public LegacyScenePageSize PageSize { get; }
public LegacySceneReachability Reachability { get; }
public Type DtoTypeMarker { get; }
/// <summary>
/// Key used to locate the source hash entry; it is not the hash value or a file path.
/// </summary>
public string SourceHashKey { get; }
public LegacySceneMutationCapability MutationCapabilities { get; }
public bool HasCapability(LegacySceneMutationCapability capability) =>
capability != LegacySceneMutationCapability.None &&
(MutationCapabilities & capability) == capability;
private static string RequireBuilderKey(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Length < 2 || value[0] != 's' ||
value.AsSpan(1).IndexOfAnyExceptInRange('0', '9') >= 0)
{
throw new ArgumentException(
"BuilderKey must be a lowercase 's' followed by digits.",
nameof(value));
}
return value;
}
private static string RequireDigits(string value, string parameterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
if (value.AsSpan().IndexOfAnyExceptInRange('0', '9') >= 0)
{
throw new ArgumentException("The value must contain digits only.", parameterName);
}
return value;
}
private static string RequireAlias(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!value.All(char.IsLetterOrDigit))
{
throw new ArgumentException(
"A cut alias may contain letters and digits only.",
nameof(value));
}
return value;
}
private static string RequireSourceHashKey(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (value.Any(character =>
!(char.IsAsciiLetterOrDigit(character) || character is '-' or '_')))
{
throw new ArgumentException(
"SourceHashKey must be an opaque key, not a path.",
nameof(value));
}
return value;
}
private static Type RequireDtoType(Type value)
{
ArgumentNullException.ThrowIfNull(value);
if (!typeof(ILegacySceneDtoMarker).IsAssignableFrom(value) ||
value == typeof(ILegacySceneDtoMarker))
{
throw new ArgumentException(
"DtoTypeMarker must be a concrete scene DTO marker type.",
nameof(value));
}
return value;
}
private static TEnum RequireDefined<TEnum>(TEnum value, string parameterName)
where TEnum : struct, Enum
{
if (!Enum.IsDefined(value))
{
throw new ArgumentOutOfRangeException(parameterName, value, "Unknown enum value.");
}
return value;
}
private static LegacySceneMutationCapability RequireCapabilities(
LegacySceneMutationCapability value)
{
if (value == LegacySceneMutationCapability.None ||
(value & ~AllKnownCapabilities) != 0)
{
throw new ArgumentOutOfRangeException(
nameof(value),
value,
"At least one known mutation capability is required.");
}
return value;
}
}

View File

@@ -0,0 +1,42 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
// These types intentionally carry no fields. Each is a unique catalog/source-hash slot;
// concrete runtime DTOs are registered separately by LegacySceneMutationBuilderRegistry.
// They are never populated from Web input and expose no K3D control surface.
public sealed class S5001SceneDtoMarker : ILegacySceneDtoMarker { private S5001SceneDtoMarker() { } }
public sealed class S5006SceneDtoMarker : ILegacySceneDtoMarker { private S5006SceneDtoMarker() { } }
public sealed class S5011SceneDtoMarker : ILegacySceneDtoMarker { private S5011SceneDtoMarker() { } }
public sealed class S5016SceneDtoMarker : ILegacySceneDtoMarker { private S5016SceneDtoMarker() { } }
public sealed class S50160SceneDtoMarker : ILegacySceneDtoMarker { private S50160SceneDtoMarker() { } }
public sealed class S5023SceneDtoMarker : ILegacySceneDtoMarker { private S5023SceneDtoMarker() { } }
public sealed class S5024SceneDtoMarker : ILegacySceneDtoMarker { private S5024SceneDtoMarker() { } }
public sealed class S5025SceneDtoMarker : ILegacySceneDtoMarker { private S5025SceneDtoMarker() { } }
public sealed class S5026SceneDtoMarker : ILegacySceneDtoMarker { private S5026SceneDtoMarker() { } }
public sealed class S5029SceneDtoMarker : ILegacySceneDtoMarker { private S5029SceneDtoMarker() { } }
public sealed class S5032SceneDtoMarker : ILegacySceneDtoMarker { private S5032SceneDtoMarker() { } }
public sealed class S5037SceneDtoMarker : ILegacySceneDtoMarker { private S5037SceneDtoMarker() { } }
public sealed class S5074SceneDtoMarker : ILegacySceneDtoMarker { private S5074SceneDtoMarker() { } }
public sealed class S5076SceneDtoMarker : ILegacySceneDtoMarker { private S5076SceneDtoMarker() { } }
public sealed class S5077SceneDtoMarker : ILegacySceneDtoMarker { private S5077SceneDtoMarker() { } }
public sealed class S5078SceneDtoMarker : ILegacySceneDtoMarker { private S5078SceneDtoMarker() { } }
public sealed class S5079SceneDtoMarker : ILegacySceneDtoMarker { private S5079SceneDtoMarker() { } }
public sealed class S5080SceneDtoMarker : ILegacySceneDtoMarker { private S5080SceneDtoMarker() { } }
public sealed class S5081SceneDtoMarker : ILegacySceneDtoMarker { private S5081SceneDtoMarker() { } }
public sealed class S5082SceneDtoMarker : ILegacySceneDtoMarker { private S5082SceneDtoMarker() { } }
public sealed class S5083SceneDtoMarker : ILegacySceneDtoMarker { private S5083SceneDtoMarker() { } }
public sealed class S5084SceneDtoMarker : ILegacySceneDtoMarker { private S5084SceneDtoMarker() { } }
public sealed class S5085SceneDtoMarker : ILegacySceneDtoMarker { private S5085SceneDtoMarker() { } }
public sealed class S5086SceneDtoMarker : ILegacySceneDtoMarker { private S5086SceneDtoMarker() { } }
public sealed class S50860SceneDtoMarker : ILegacySceneDtoMarker { private S50860SceneDtoMarker() { } }
public sealed class S5087SceneDtoMarker : ILegacySceneDtoMarker { private S5087SceneDtoMarker() { } }
public sealed class S5088SceneDtoMarker : ILegacySceneDtoMarker { private S5088SceneDtoMarker() { } }
public sealed class S6001SceneDtoMarker : ILegacySceneDtoMarker { private S6001SceneDtoMarker() { } }
public sealed class S6067SceneDtoMarker : ILegacySceneDtoMarker { private S6067SceneDtoMarker() { } }
public sealed class S8001SceneDtoMarker : ILegacySceneDtoMarker { private S8001SceneDtoMarker() { } }
public sealed class S8003SceneDtoMarker : ILegacySceneDtoMarker { private S8003SceneDtoMarker() { } }
public sealed class S8010SceneDtoMarker : ILegacySceneDtoMarker { private S8010SceneDtoMarker() { } }
public sealed class S8018SceneDtoMarker : ILegacySceneDtoMarker { private S8018SceneDtoMarker() { } }
public sealed class S8067SceneDtoMarker : ILegacySceneDtoMarker { private S8067SceneDtoMarker() { } }
public sealed class S8086SceneDtoMarker : ILegacySceneDtoMarker { private S8086SceneDtoMarker() { } }

View File

@@ -0,0 +1,78 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Marker for trusted, scene-specific data created by the native DB layer.
/// Web messages never deserialize into these contracts.
/// </summary>
public interface ILegacySceneData
{
}
/// <summary>
/// Converts one explicit scene DTO into a deterministic COM-neutral mutation sequence.
/// </summary>
public interface ILegacySceneMutationBuilder<in TData>
where TData : ILegacySceneData
{
string BuilderKey { get; }
IReadOnlyList<PlayoutMutation> Build(TData data);
}
public sealed class LegacySceneDataException : Exception
{
public LegacySceneDataException(string message)
: base(message)
{
}
}
internal static class LegacySceneBuilderGuard
{
public static T NotNull<T>(T? value, string name)
where T : class => value ?? throw new LegacySceneDataException(
$"Scene data field '{name}' is required.");
public static string Text(string? value, string name, bool allowEmpty = false)
{
if (value is null || value.Length > 4_096 || value.Any(char.IsControl) ||
(!allowEmpty && string.IsNullOrWhiteSpace(value)))
{
throw new LegacySceneDataException(
$"Scene data field '{name}' is invalid.");
}
return value;
}
public static IReadOnlyList<T> Count<T>(
IReadOnlyList<T>? values,
int expected,
string name)
{
if (values is null || values.Count != expected)
{
throw new LegacySceneDataException(
$"Scene data field '{name}' must contain exactly {expected} items.");
}
return values;
}
public static IReadOnlyList<T> Range<T>(
IReadOnlyList<T>? values,
int minimum,
int maximum,
string name)
{
if (values is null || values.Count < minimum || values.Count > maximum)
{
throw new LegacySceneDataException(
$"Scene data field '{name}' must contain between {minimum} and {maximum} items.");
}
return values;
}
}

View File

@@ -0,0 +1,273 @@
#nullable enable
using System.Collections.ObjectModel;
using System.Reflection;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Read-only metadata for one trusted scene mutation builder. Instances can only be
/// created by <see cref="LegacySceneMutationBuilderRegistry"/>.
/// </summary>
public sealed class LegacySceneMutationBuilderRegistration
{
internal LegacySceneMutationBuilderRegistration(string builderKey, Type dataType)
{
BuilderKey = builderKey;
DataType = dataType;
}
/// <summary>
/// Closed builder identity from <see cref="LegacySceneCatalog"/>.
/// </summary>
public string BuilderKey { get; }
/// <summary>
/// Trusted native-data contract accepted by the registered builder.
/// </summary>
public Type DataType { get; }
}
/// <summary>
/// Closed, COM-neutral dispatch table for every ported legacy scene builder.
/// The registry accepts only a catalog builder key and an <see cref="ILegacySceneData"/>
/// instance; it has no raw K3D method, object-name, mutation, or asset-path input.
/// </summary>
public sealed class LegacySceneMutationBuilderRegistry
{
private static readonly Lazy<LegacySceneMutationBuilderRegistry> DefaultRegistry =
new(CreateDefault, LazyThreadSafetyMode.ExecutionAndPublication);
private readonly IReadOnlyDictionary<string, BuilderAdapter> _byBuilderKey;
private LegacySceneMutationBuilderRegistry(
IReadOnlyDictionary<string, BuilderAdapter> byBuilderKey,
IReadOnlyList<LegacySceneMutationBuilderRegistration> registrations)
{
_byBuilderKey = byBuilderKey;
Registrations = registrations;
}
/// <summary>
/// Registry discovered once from the Core assembly and validated against all 35
/// entries in <see cref="LegacySceneCatalog"/>.
/// </summary>
public static LegacySceneMutationBuilderRegistry Default => DefaultRegistry.Value;
/// <summary>
/// Registrations in catalog order. The collection and its entries are immutable.
/// </summary>
public IReadOnlyList<LegacySceneMutationBuilderRegistration> Registrations { get; }
/// <summary>
/// Looks up a closed builder identity. Invalid or unregistered strings are rejected
/// without being interpreted as a scene file, type name, or K3D object name.
/// </summary>
public bool TryGetRegistration(
string? builderKey,
out LegacySceneMutationBuilderRegistration? registration)
{
registration = null;
if (!IsBuilderKey(builderKey) ||
!_byBuilderKey.TryGetValue(builderKey!, out var adapter))
{
return false;
}
registration = adapter.Registration;
return true;
}
/// <summary>
/// Dispatches trusted scene data to the builder registered for <paramref name="builderKey"/>.
/// A DTO registered for any other key is rejected before its builder is called.
/// </summary>
public IReadOnlyList<PlayoutMutation> Build(
string builderKey,
ILegacySceneData data)
{
ArgumentNullException.ThrowIfNull(data);
if (!IsBuilderKey(builderKey) ||
!_byBuilderKey.TryGetValue(builderKey, out var adapter))
{
throw new ArgumentException(
"The scene builder key is not registered.",
nameof(builderKey));
}
return adapter.Build(data);
}
private static LegacySceneMutationBuilderRegistry CreateDefault()
{
var builderContract = typeof(ILegacySceneMutationBuilder<>);
var candidates = typeof(LegacySceneMutationBuilderRegistry)
.Assembly
.DefinedTypes
.Where(type => type.IsClass && !type.IsAbstract)
.Select(type => new
{
Type = type,
Contracts = type.ImplementedInterfaces
.Where(contract =>
contract.IsGenericType &&
contract.GetGenericTypeDefinition() == builderContract)
.ToArray()
})
.Where(candidate => candidate.Contracts.Length != 0)
.OrderBy(candidate => candidate.Type.FullName, StringComparer.Ordinal)
.ToArray();
var adapters = new Dictionary<string, BuilderAdapter>(StringComparer.Ordinal);
foreach (var candidate in candidates)
{
if (candidate.Contracts.Length != 1)
{
throw new InvalidOperationException(
"A legacy scene builder must implement exactly one typed builder contract.");
}
var constructor = candidate.Type.GetConstructor(
BindingFlags.Instance | BindingFlags.Public,
binder: null,
Type.EmptyTypes,
modifiers: null);
if (constructor is null)
{
throw new InvalidOperationException(
"Every legacy scene builder must expose a public parameterless constructor.");
}
var dataType = candidate.Contracts[0].GenericTypeArguments[0];
ValidateDataType(dataType);
var builder = constructor.Invoke(null);
var adapter = CreateAdapter(dataType, builder);
if (!IsBuilderKey(adapter.Registration.BuilderKey))
{
throw new InvalidOperationException(
"A legacy scene builder returned an invalid builder key.");
}
if (!adapters.TryAdd(adapter.Registration.BuilderKey, adapter))
{
throw new InvalidOperationException(
"Every legacy scene builder key must have exactly one implementation.");
}
}
var catalogKeys = LegacySceneCatalog.Definitions
.Select(definition => definition.BuilderKey)
.ToArray();
var discoveredKeys = adapters.Keys
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
if (adapters.Count != LegacySceneCatalog.ExpectedDefinitionCount ||
!catalogKeys.OrderBy(key => key, StringComparer.Ordinal)
.SequenceEqual(discoveredKeys, StringComparer.Ordinal))
{
throw new InvalidOperationException(
"Legacy scene builders must have exact one-to-one parity with the 35-scene catalog.");
}
var orderedAdapters = catalogKeys
.Select(key => adapters[key])
.ToArray();
var registrations = Array.AsReadOnly(
orderedAdapters.Select(adapter => adapter.Registration).ToArray());
var readOnlyAdapters = new ReadOnlyDictionary<string, BuilderAdapter>(
orderedAdapters.ToDictionary(
adapter => adapter.Registration.BuilderKey,
StringComparer.Ordinal));
return new LegacySceneMutationBuilderRegistry(readOnlyAdapters, registrations);
}
private static BuilderAdapter CreateAdapter(Type dataType, object builder)
{
var factory = typeof(LegacySceneMutationBuilderRegistry)
.GetMethod(
nameof(CreateTypedAdapter),
BindingFlags.Static | BindingFlags.NonPublic)!
.MakeGenericMethod(dataType);
try
{
return (BuilderAdapter)factory.Invoke(null, [builder])!;
}
catch (TargetInvocationException exception) when (exception.InnerException is not null)
{
throw exception.InnerException;
}
}
private static BuilderAdapter CreateTypedAdapter<TData>(object builder)
where TData : ILegacySceneData =>
new TypedBuilderAdapter<TData>((ILegacySceneMutationBuilder<TData>)builder);
private static void ValidateDataType(Type dataType)
{
if (dataType == typeof(ILegacySceneData) ||
!typeof(ILegacySceneData).IsAssignableFrom(dataType) ||
dataType.IsInterface ||
dataType.ContainsGenericParameters)
{
throw new InvalidOperationException(
"A legacy scene builder must use a closed, concrete scene-data contract.");
}
}
private static bool IsBuilderKey(string? value)
{
if (value is null || value.Length is < 2 or > 16 || value[0] != 's')
{
return false;
}
return value.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0;
}
private abstract class BuilderAdapter
{
protected BuilderAdapter(string builderKey, Type dataType)
{
Registration = new LegacySceneMutationBuilderRegistration(builderKey, dataType);
}
public LegacySceneMutationBuilderRegistration Registration { get; }
public abstract IReadOnlyList<PlayoutMutation> Build(ILegacySceneData data);
}
private sealed class TypedBuilderAdapter<TData> : BuilderAdapter
where TData : ILegacySceneData
{
private readonly ILegacySceneMutationBuilder<TData> _builder;
public TypedBuilderAdapter(ILegacySceneMutationBuilder<TData> builder)
: base(builder.BuilderKey, typeof(TData))
{
_builder = builder;
}
public override IReadOnlyList<PlayoutMutation> Build(ILegacySceneData data)
{
if (data is not TData typedData)
{
throw new ArgumentException(
"The scene data does not match the registered builder key.",
nameof(data));
}
var mutations = _builder.Build(typedData) ??
throw new InvalidOperationException(
"A legacy scene mutation builder returned no mutation collection.");
if (mutations.Any(mutation => mutation is null))
{
throw new InvalidOperationException(
"A legacy scene mutation builder returned a null mutation.");
}
return Array.AsReadOnly(mutations.ToArray());
}
}
}

View File

@@ -0,0 +1,99 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Safe, read-only representation of a typed builder mutation for the operator preview.
/// Asset paths are deliberately replaced with a configured marker.
/// </summary>
public sealed record LegacyScenePreviewField(string Kind, string Name, string Value);
public static class LegacyScenePreviewFactory
{
private const int MaximumFields = 4_096;
private const int MaximumValueLength = 16_384;
public static IReadOnlyList<LegacyScenePreviewField> Create(
IReadOnlyList<PlayoutMutation> mutations)
{
ArgumentNullException.ThrowIfNull(mutations);
if (mutations.Count > MaximumFields)
{
throw new LegacySceneDataException("The scene preview exceeds its bounded field count.");
}
var fields = new List<LegacyScenePreviewField>(mutations.Count);
foreach (var mutation in mutations)
{
ArgumentNullException.ThrowIfNull(mutation);
LegacyScenePreviewField field = mutation switch
{
PlayoutSetValue value => new("value", value.ObjectName, value.Value),
PlayoutSetAssetValue asset => new("asset", asset.ObjectName, "configured"),
PlayoutSetVisible visible => new(
"visible", visible.ObjectName, visible.IsVisible ? "true" : "false"),
PlayoutSetFaceColor color => new(
"color", color.ObjectName,
$"{color.Red},{color.Green},{color.Blue},{color.Alpha}"),
PlayoutSetPosition position => new(
"position", position.ObjectName,
$"{F(position.X)},{F(position.Y)},{F(position.Z)} [{position.Components}]"),
PlayoutSetPositionKey position => new(
"position-key", position.ObjectName,
$"key={position.KeyIndex};{F(position.X)},{F(position.Y)},{F(position.Z)} [{position.Components}]"),
PlayoutSetScale scale => new(
"scale", scale.ObjectName,
$"{F(scale.X)},{F(scale.Y)},{F(scale.Z)} [{scale.Components}]"),
PlayoutSetCropKey crop => new(
"crop", crop.ObjectName,
$"key={crop.KeyIndex};{F(crop.Left)},{F(crop.Top)},{F(crop.Right)},{F(crop.Bottom)} [{crop.Edges}]"),
PlayoutSetCircleAngleKey angle => new(
"angle", angle.ObjectName,
$"key={angle.KeyIndex};{F(angle.Start)},{F(angle.End)} [{angle.Components}]"),
PlayoutSetPathPoints path => new(
"path", path.ObjectName, Points(path.Points)),
PlayoutSetPathShapePoints path => new(
"path-shape", path.ObjectName, Points(path.Points)),
PlayoutUseBackground background => new(
"background", "scene", background.IsEnabled ? "enabled" : "disabled"),
PlayoutSetBackgroundTexture => new(
"background-texture", "scene", "configured"),
PlayoutSetBackgroundVideo video => new(
"background-video", "scene",
$"configured;loop={video.LoopCount};infinite={video.LoopInfinite.ToString().ToLowerInvariant()}"),
_ => throw new LegacySceneDataException(
"The scene preview encountered an unsupported typed mutation.")
};
if (string.IsNullOrWhiteSpace(field.Name) || field.Name.Length > 128 ||
field.Name.Any(char.IsControl) || field.Value.Length > MaximumValueLength ||
field.Value.Any(character => character is '\0' or '\r' or '\n'))
{
throw new LegacySceneDataException("The scene preview contains an invalid field.");
}
fields.Add(field);
}
return Array.AsReadOnly(fields.ToArray());
}
private static string Points(IReadOnlyList<PlayoutPoint> points)
{
ArgumentNullException.ThrowIfNull(points);
var value = string.Join(
";",
points.Select(point => $"{F(point.X)},{F(point.Y)},{F(point.Z)}"));
if (value.Length > MaximumValueLength)
{
throw new LegacySceneDataException("The scene preview path exceeds its bounded size.");
}
return $"count={points.Count};{value}";
}
private static string F(float value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,80 @@
#nullable enable
using System.Collections.ObjectModel;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Closed refresh intervals copied from MainForm.Show_PlayList's m_time assignments.
/// Browser input cannot select or alter these values.
/// </summary>
public static class LegacySceneRefreshPolicy
{
public static TimeSpan RecurringInterval { get; } = TimeSpan.FromSeconds(3);
private static readonly IReadOnlyDictionary<string, TimeSpan> Intervals =
new ReadOnlyDictionary<string, TimeSpan>(new Dictionary<string, TimeSpan>(
StringComparer.Ordinal)
{
["5001"] = Seconds(2),
["N5001"] = Seconds(2),
["5006"] = Seconds(5),
["5011"] = Seconds(5),
["5016"] = Seconds(5),
["50160"] = Seconds(30),
["5023"] = Seconds(30),
["5024"] = Seconds(60),
["5025"] = Seconds(30),
["5026"] = Seconds(8),
["5029"] = Seconds(7),
["5032"] = Seconds(5),
["5037"] = Seconds(5),
["5074"] = Seconds(7),
["5076"] = Seconds(3_000_000),
["5077"] = Seconds(7),
["5078"] = Seconds(30),
["5079"] = Seconds(3_000_000),
["5080"] = Seconds(3_000_000),
["5081"] = Seconds(3_000_000),
["5082"] = Seconds(6),
["5083"] = Seconds(60),
["5084"] = Seconds(5),
["5085"] = Seconds(30),
["5086"] = Seconds(7),
["50860"] = Seconds(7),
["5087"] = Seconds(7),
["5088"] = Seconds(7),
["6001"] = Seconds(30),
["6067"] = Seconds(30),
["8001"] = Seconds(120),
["8002"] = Seconds(120),
["8003"] = Seconds(8),
["8018"] = Seconds(5),
["8032"] = Seconds(5),
["8035"] = Seconds(8),
["8040"] = Seconds(8),
["8046"] = Seconds(8),
["8051"] = Seconds(8),
["8056"] = Seconds(8),
["8061"] = Seconds(8),
["8067"] = Seconds(30),
["5068"] = Seconds(30),
["5070"] = Seconds(30),
["5072"] = Seconds(30)
});
public static int Count => Intervals.Count;
public static bool TryGetInterval(string? cutCode, out TimeSpan interval)
{
if (string.IsNullOrWhiteSpace(cutCode))
{
interval = default;
return false;
}
return Intervals.TryGetValue(cutCode, out interval);
}
private static TimeSpan Seconds(int seconds) => TimeSpan.FromSeconds(seconds);
}

View File

@@ -0,0 +1,177 @@
#nullable enable
using System.Collections.ObjectModel;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
/// <summary>
/// Immutable runtime-to-catalog coverage result. Missing aliases identify catalog aliases
/// with no router route; unexpected aliases identify routes with no reachable MainForm
/// builder. Conditional aliases may intentionally cover more than one builder.
/// </summary>
public sealed record LegacySceneRuntimeCoverageReport(
IReadOnlyList<string> ReachableBuilderKeys,
IReadOnlyList<string> UnreachableBuilderKeys,
IReadOnlyList<string> ExpectedCutCodes,
IReadOnlyList<string> RegisteredCutCodes,
IReadOnlyList<string> MissingCutCodes,
IReadOnlyList<string> UnexpectedCutCodes,
IReadOnlyList<string> UncoveredBuilderKeys,
IReadOnlyDictionary<string, IReadOnlyList<string>> ConditionalAliasOwners)
{
public bool IsComplete =>
ReachableBuilderKeys.Count == LegacySceneRuntimeCoverage.ExpectedReachableBuilderCount &&
ExpectedCutCodes.Count == LegacySceneRuntimeCoverage.ExpectedActiveAliasCount &&
RegisteredCutCodes.Count == LegacySceneRuntimeCoverage.ExpectedActiveAliasCount &&
MissingCutCodes.Count == 0 &&
UnexpectedCutCodes.Count == 0 &&
UncoveredBuilderKeys.Count == 0;
}
/// <summary>
/// Validates that the application composition root has one closed router entry for every
/// active MainForm cut alias and no others. The catalog owns alias-to-builder semantics:
/// 8018/8032/5032 intentionally cover both s5032 and s8018, while s8086 has no MainForm
/// alias and must never be registered as an active runtime cut.
/// </summary>
public static class LegacySceneRuntimeCoverage
{
public const int ExpectedReachableBuilderCount = 34;
public const int ExpectedActiveAliasCount = 45;
private static readonly RuntimeCatalogContract Contract = CreateContract();
public static IReadOnlyList<string> ExpectedCutCodes => Contract.ExpectedCutCodes;
public static LegacySceneRuntimeCoverageReport Analyze(
LegacySceneDataSourceRouter router)
{
ArgumentNullException.ThrowIfNull(router);
var registered = Array.AsReadOnly(
router.CutCodes.OrderBy(code => code, StringComparer.Ordinal).ToArray());
var registeredSet = registered.ToHashSet(StringComparer.Ordinal);
var expectedSet = Contract.ExpectedCutCodes.ToHashSet(StringComparer.Ordinal);
var missing = Array.AsReadOnly(
Contract.ExpectedCutCodes
.Where(code => !registeredSet.Contains(code))
.ToArray());
var unexpected = Array.AsReadOnly(
registered
.Where(code => !expectedSet.Contains(code))
.ToArray());
var uncovered = Array.AsReadOnly(
Contract.ReachableDefinitions
.Where(definition => definition.CutAliases.Any(alias => !registeredSet.Contains(alias)))
.Select(definition => definition.BuilderKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray());
return new LegacySceneRuntimeCoverageReport(
Contract.ReachableBuilderKeys,
Contract.UnreachableBuilderKeys,
Contract.ExpectedCutCodes,
registered,
missing,
unexpected,
uncovered,
Contract.ConditionalAliasOwners);
}
public static LegacySceneRuntimeCoverageReport Validate(
LegacySceneDataSourceRouter router)
{
var report = Analyze(router);
if (!report.IsComplete)
{
throw new InvalidOperationException(
"The legacy scene-data router does not exactly cover the active MainForm aliases.");
}
return report;
}
private static RuntimeCatalogContract CreateContract()
{
var definitions = LegacySceneCatalog.Definitions;
var reachable = definitions
.Where(definition => definition.Reachability is
LegacySceneReachability.MainFormDispatch or
LegacySceneReachability.ConditionalMainFormDispatch)
.OrderBy(definition => definition.BuilderKey, StringComparer.Ordinal)
.ToArray();
var unreachable = definitions
.Where(definition => definition.Reachability ==
LegacySceneReachability.NoMainFormDispatch)
.OrderBy(definition => definition.BuilderKey, StringComparer.Ordinal)
.ToArray();
var aliases = reachable
.SelectMany(definition => definition.CutAliases)
.Distinct(StringComparer.Ordinal)
.OrderBy(alias => alias, StringComparer.Ordinal)
.ToArray();
if (reachable.Length != ExpectedReachableBuilderCount ||
unreachable.Length != 1 ||
unreachable[0].BuilderKey != "s8086" ||
unreachable[0].CutAliases.Count != 0 ||
reachable.Any(definition => definition.CutAliases.Count == 0) ||
aliases.Length != ExpectedActiveAliasCount ||
aliases.Contains("8086", StringComparer.Ordinal))
{
throw new InvalidOperationException(
"The legacy scene catalog does not match the runtime coverage baseline.");
}
var conditionalDefinitions = reachable
.Where(definition => definition.Reachability ==
LegacySceneReachability.ConditionalMainFormDispatch)
.ToArray();
if (conditionalDefinitions.Length != 2 ||
!conditionalDefinitions.Select(definition => definition.BuilderKey)
.ToHashSet(StringComparer.Ordinal)
.SetEquals(["s5032", "s8018"]))
{
throw new InvalidOperationException(
"The conditional legacy scene catalog contract is invalid.");
}
var aliasOwners = reachable
.SelectMany(definition => definition.CutAliases.Select(alias =>
(Alias: alias, definition.BuilderKey)))
.GroupBy(value => value.Alias, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<string>)Array.AsReadOnly(
group.Select(value => value.BuilderKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray()),
StringComparer.Ordinal);
var sharedAliases = aliasOwners
.Where(pair => pair.Value.Count > 1)
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
var expectedSharedAliases = new HashSet<string>(
["5032", "8018", "8032"],
StringComparer.Ordinal);
if (!sharedAliases.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(expectedSharedAliases) ||
sharedAliases.Values.Any(owners =>
!owners.ToHashSet(StringComparer.Ordinal).SetEquals(["s5032", "s8018"])))
{
throw new InvalidOperationException(
"The shared conditional cut-alias contract is invalid.");
}
return new RuntimeCatalogContract(
Array.AsReadOnly(reachable),
Array.AsReadOnly(reachable.Select(definition => definition.BuilderKey).ToArray()),
Array.AsReadOnly(unreachable.Select(definition => definition.BuilderKey).ToArray()),
Array.AsReadOnly(aliases),
new ReadOnlyDictionary<string, IReadOnlyList<string>>(sharedAliases));
}
private sealed record RuntimeCatalogContract(
IReadOnlyList<LegacySceneDefinition> ReachableDefinitions,
IReadOnlyList<string> ReachableBuilderKeys,
IReadOnlyList<string> UnreachableBuilderKeys,
IReadOnlyList<string> ExpectedCutCodes,
IReadOnlyDictionary<string, IReadOnlyList<string>> ConditionalAliasOwners);
}

View File

@@ -0,0 +1,200 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed class S5076SceneDataLoader
{
private const string Query = """
SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5
FROM INPUT_PIE
WHERE STOCK_NAME = :stockName
""";
private readonly IDataQueryExecutor _executor;
public S5076SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5076SceneData> LoadAsync(
string stockName,
CancellationToken cancellationToken = default)
{
stockName = LegacySceneBuilderGuard.Text(stockName, nameof(stockName));
var spec = new DataQuerySpec(
Query,
[new DataQueryParameter("stockName", stockName, DbType.String)]);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"INPUT_PIE",
spec,
cancellationToken).ConfigureAwait(false);
var row = SceneDataTableReader.SingleRow(
table,
"STOCK_NAME", "BASE_DATE",
"GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5");
var slices = new RevenueSliceData?[5];
for (var index = 0; index < slices.Length; index++)
{
var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}", allowEmpty: true);
slices[index] = ParseSlice(value);
}
return new S5076SceneData(
SceneDataTableReader.RequiredString(row, "STOCK_NAME"),
SceneDataTableReader.RequiredString(row, "BASE_DATE"),
slices);
}
private static RevenueSliceData? ParseSlice(string value)
{
if (value.Length == 0)
{
return null;
}
var parts = value.Split('_');
if (parts.Length != 2 || parts[0].Length == 0)
{
throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue slice.");
}
var label = parts[0];
var percentageText = parts[1];
if (percentageText is "" or "-")
{
percentageText = "0";
}
if (!double.TryParse(
percentageText,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var percentage) ||
!double.IsFinite(percentage))
{
throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue percentage.");
}
return new RevenueSliceData(label, percentageText, percentage);
}
}
public sealed class S5081SceneDataLoader
{
private const string Query = """
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6
FROM INPUT_PROFIT
WHERE STOCK_NAME = :stockName
""";
private readonly IDataQueryExecutor _executor;
public S5081SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5081SceneData> LoadAsync(
string stockName,
CancellationToken cancellationToken = default)
{
stockName = LegacySceneBuilderGuard.Text(stockName, nameof(stockName));
var spec = new DataQuerySpec(
Query,
[new DataQueryParameter("stockName", stockName, DbType.String)]);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"INPUT_PROFIT",
spec,
cancellationToken).ConfigureAwait(false);
var row = SceneDataTableReader.SingleRow(
table,
"STOCK_NAME",
"GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6");
var quarters = new QuarterlyProfitData[6];
for (var index = 0; index < quarters.Length; index++)
{
var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}");
quarters[index] = ParseQuarter(value);
}
return new S5081SceneData(
SceneDataTableReader.RequiredString(row, "STOCK_NAME"),
quarters);
}
private static QuarterlyProfitData ParseQuarter(string value)
{
var parts = value.Split('_');
if (parts.Length != 2 || parts[0].Length == 0)
{
throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value.");
}
var label = parts[0];
var numberText = parts[1];
if (numberText.Length == 0)
{
numberText = "0";
}
if (!int.TryParse(
numberText,
NumberStyles.Integer | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var number))
{
throw new LegacySceneDataException("INPUT_PROFIT contains an invalid profit value.");
}
return new QuarterlyProfitData(label, number);
}
}
internal static class SceneDataTableReader
{
public static DataRow SingleRow(DataTable? table, params string[] requiredColumns)
{
if (table is null || table.Rows.Count != 1)
{
throw new LegacySceneDataException("The scene query must return exactly one row.");
}
var available = table.Columns
.Cast<DataColumn>()
.Select(column => column.ColumnName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (requiredColumns.Any(column => !available.Contains(column)))
{
throw new LegacySceneDataException("The scene query returned an unexpected schema.");
}
return table.Rows[0];
}
public static string RequiredString(
DataRow row,
string columnName,
bool allowEmpty = false)
{
var value = row[columnName];
if (value is null or DBNull)
{
if (allowEmpty)
{
return string.Empty;
}
throw new LegacySceneDataException("The scene query returned a required null value.");
}
var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
return LegacySceneBuilderGuard.Text(text, columnName, allowEmpty);
}
}

View File

@@ -0,0 +1,733 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed class S5016SceneDataLoader
{
private readonly IDataQueryExecutor _executor;
public S5016SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5016SceneData> LoadAsync(
S5016PanelTarget target,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(target))
{
throw EquityPanelLoaderReader.InvalidRequest();
}
var spec = PanelQuoteQueryFactory.Create(target);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S5016_PANEL",
spec,
cancellationToken).ConfigureAwait(false);
var data = new S5016SceneData(target, PanelQuoteTableMapper.Map(table, 3));
_ = new S5016SceneMutationBuilder().Build(data);
return data;
}
}
public sealed class S50160SceneDataLoader
{
private readonly IDataQueryExecutor _executor;
public S50160SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S50160SceneData> LoadAsync(
S50160PanelTarget target,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(target))
{
throw EquityPanelLoaderReader.InvalidRequest();
}
var spec = PanelQuoteQueryFactory.Create(target);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S50160_PANEL",
spec,
cancellationToken).ConfigureAwait(false);
var data = new S50160SceneData(target, PanelQuoteTableMapper.Map(table, 2));
_ = new S50160SceneMutationBuilder().Build(data);
return data;
}
}
internal static class PanelQuoteQueryFactory
{
public static DataQuerySpec Create(S5016PanelTarget target) =>
Create(Profile(target));
public static DataQuerySpec Create(S50160PanelTarget target) =>
Create(Profile(target));
private static DataQuerySpec Create(PanelProfile profile) => profile.Source switch
{
PanelSource.WorldIndex => World(profile, "t_world_ix_eq_master", "t_world_ix_eq_sise", "b.f_sign"),
PanelSource.WorldFuture => World(
profile,
"t_world_future_master",
"t_world_future_sise",
"CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " +
"WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"),
PanelSource.WorldForex => World(
profile,
"t_world_forex_master",
"t_world_forex_sise",
"CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " +
"WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"),
PanelSource.DomesticExchange => DomesticExchange(profile),
PanelSource.MajorIndex200 => Major(forecast: false, futures: false),
PanelSource.MajorIndexFutures => Major(forecast: false, futures: true),
PanelSource.ExpectedIndex200 => Major(forecast: true, futures: false),
PanelSource.ExpectedIndexFutures => Major(forecast: true, futures: true),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
private static DataQuerySpec World(
PanelProfile profile,
string masterTable,
string quoteTable,
string signalExpression)
{
var selects = profile.Symbols.Select((symbol, zeroBased) =>
{
var index = zeroBased + 1;
return $"""
SELECT
{index} ORDINAL,
a.f_input_name NAME,
a.f_input_name PART_NAME,
ROUND(b.f_last, 4) PRICE,
ROUND(b.f_diff, 4) CHANGE,
ROUND(b.f_rate, 4) RATE,
{signalExpression} SIGNAL
FROM {masterTable} a, {quoteTable} b
WHERE a.f_symb = b.f_symb
AND a.f_symb = :symbol{index}
""";
});
var sql = string.Join("\nUNION ALL\n", selects) + "\nORDER BY ORDINAL";
var parameters = profile.Symbols
.Select((symbol, index) => new DataQueryParameter(
"symbol" + (index + 1),
symbol,
DbType.String))
.ToArray();
return EquityPanelLoaderReader.Spec(DataSourceKind.Oracle, sql, parameters);
}
private static DataQuerySpec DomesticExchange(PanelProfile profile)
{
var selects = profile.Symbols.Select((symbol, zeroBased) =>
{
var index = zeroBased + 1;
return $"""
SELECT
{index} ORDINAL,
:display{index} NAME,
CAST(NULL AS VARCHAR2(256)) PART_NAME,
a.f_curr_price PRICE,
a.f_net_chg CHANGE,
ROUND(a.f_net_chg / DECODE(
a.f_chg_type,
'+', (a.f_curr_price / 100) - (ABS(a.f_net_chg) / 100),
'-', (a.f_curr_price / 100) + (a.f_net_chg / 100),
1), 2) RATE,
a.f_chg_type SIGNAL
FROM t_input_index a, t_input_code b
WHERE a.f_input_code = b.f_input_code
AND a.f_input_code = :symbol{index}
""";
});
var sql = string.Join("\nUNION ALL\n", selects) + "\nORDER BY ORDINAL";
var parameters = new List<DataQueryParameter>(profile.Symbols.Count * 2);
for (var index = 0; index < profile.Symbols.Count; index++)
{
parameters.Add(new DataQueryParameter(
"display" + (index + 1),
profile.DisplayNames![index],
DbType.String));
parameters.Add(new DataQueryParameter(
"symbol" + (index + 1),
profile.Symbols[index],
DbType.String));
}
return EquityPanelLoaderReader.Spec(
DataSourceKind.Oracle,
sql,
parameters.ToArray());
}
private static DataQuerySpec Major(bool forecast, bool futures)
{
var firstTable = forecast ? "t_fo_index" : "t_index";
var secondTable = forecast ? "T_KOSDAQ_FO_INDEX" : "T_KOSDAQ_INDEX";
var thirdTable = forecast ? "T_200_FO_INDEX" : "T_200_INDEX";
var third = futures
? $"""
SELECT
3 ORDINAL,
:display3 NAME,
CAST(NULL AS VARCHAR2(256)) PART_NAME,
part_idx PRICE,
part_chg CHANGE,
ROUND(part_chg / DECODE(
chg_type,
'+', (part_idx / 100) - (part_chg / 100),
'-', (part_idx / 100) + (part_chg / 100),
1), 2) RATE,
chg_type SIGNAL
FROM (
SELECT
DECODE(
a.{(forecast ? "f_fo_price" : "f_curr_price")},
0,
{(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE / 100")},
{(forecast ? "a.f_fo_price" : "a.f_curr_price / 100")}) part_idx,
DECODE(
a.{(forecast ? "f_fo_price" : "f_curr_price")},
0,
0,
(a.{(forecast ? "f_fo_price" : "f_curr_price")} -
{(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE * 100")}) / 100) part_chg,
DECODE(
a.{(forecast ? "f_fo_price" : "f_curr_price")},
0,
' ',
DECODE(SIGN(
a.{(forecast ? "f_fo_price" : "f_curr_price")} -
{(forecast ? "b.F_JUN_LAST_PRICE" : "b.F_JUN_LAST_PRICE * 100")}),
1, '+', -1, '-', 0, ' ')) chg_type
FROM t_sunmul_online a, t_sunmul_batch b
WHERE a.f_stock_code = b.f_stock_code
AND a.f_stock_seq = 1
AND b.f_market_date = (SELECT MAX(OPEN_DAY) FROM v_open_day)
AND b.F_MONTH_GUBUN = '1'
)
"""
: $"""
SELECT
3 ORDINAL,
:display3 NAME,
CAST(NULL AS VARCHAR2(256)) PART_NAME,
f_part_idx / 100 PRICE,
f_part_chg / 100 CHANGE,
ROUND((f_part_chg / DECODE(
f_chg_type,
'+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg,
1)) * 100, 2) RATE,
f_chg_type SIGNAL
FROM {thirdTable}
WHERE f_part_code = :partCode3
""";
var sql = $"""
SELECT
1 ORDINAL,
:display1 NAME,
CAST(NULL AS VARCHAR2(256)) PART_NAME,
f_part_idx / 100 PRICE,
f_part_chg / 100 CHANGE,
ROUND((f_part_chg / DECODE(
f_chg_type,
'+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg,
1)) * 100, 2) RATE,
f_chg_type SIGNAL
FROM {firstTable}
WHERE f_part_code = :partCode1
UNION ALL
SELECT
2 ORDINAL,
:display2 NAME,
CAST(NULL AS VARCHAR2(256)) PART_NAME,
f_part_idx / 100 PRICE,
f_part_chg / 100 CHANGE,
ROUND((f_part_chg / DECODE(
f_chg_type,
'+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg,
1)) * 100, 2) RATE,
f_chg_type SIGNAL
FROM {secondTable}
WHERE f_part_code = :partCode2
UNION ALL
{third}
ORDER BY ORDINAL
""";
var parameters = new List<DataQueryParameter>
{
new("display1", "KOSPI", DbType.String),
new("partCode1", "001", DbType.String),
new("display2", "KOSQ", DbType.String),
new("partCode2", "001", DbType.String),
new("display3", futures ? "\uC120\uBB3C" : "KOSPI200", DbType.String)
};
if (!futures)
{
parameters.Add(new DataQueryParameter("partCode3", "029", DbType.String));
}
return EquityPanelLoaderReader.Spec(
DataSourceKind.Oracle,
sql,
parameters.ToArray());
}
private static PanelProfile Profile(S5016PanelTarget target) => target switch
{
S5016PanelTarget.UnitedStatesIndices => WorldIndex("DJI@DJI", "NAS@IXIC", "SPI@SPX"),
S5016PanelTarget.GreaterChinaIndices => WorldIndex("HSI@HSI", "SHS@000001", "TWS@TI01"),
S5016PanelTarget.EuropeanIndices => WorldIndex("LNS@FTSE100", "PAS@CAC40", "XTR@DAX30"),
S5016PanelTarget.AsianIndices => WorldIndex("SHS@000001", "TWS@TI01", "NII@NI225"),
S5016PanelTarget.UnitedStatesTreasuries => WorldIndex("GVO@TR02Y", "GVO@TR03Y", "GVO@TR05Y"),
S5016PanelTarget.BondYields => WorldIndex("KIR@CMAA", "KIR@NB03Y", "KIR@CD91"),
S5016PanelTarget.Oil => WorldFuture("SPT@DU", "SPT@EB", "SPT@CL"),
S5016PanelTarget.Minerals => WorldFuture("COM@GC", "COM@SI", "LME@CDY"),
S5016PanelTarget.FoodMaterials => WorldFuture("CBT$CORN", "CBT@SOYBEAN", "CBT@WHEAT"),
S5016PanelTarget.OverseasExchangeRates => new(
PanelSource.WorldForex,
["USDJPYCOMP", "EURUSDCOMP", "USDCNYCOMP"]),
S5016PanelTarget.DomesticExchangeRates => new(
PanelSource.DomesticExchange,
["A09", "A10", "A91"],
["\uC6D0/\uB2EC\uB7EC", "\uC6D0/\uC5D4", "\uC6D0/\uC720\uB85C"]),
S5016PanelTarget.AgricultureWheatCorn => WorldFuture("MK@WHEAT", "CBT$CORN", "CBT@WHEAT"),
S5016PanelTarget.AgricultureSoyRice => WorldFuture("CBT@SOYBEAN", "MK@BEAN", "MK@RICE"),
S5016PanelTarget.AgricultureCoffeeCocoaSugar => WorldFuture("NYB@KC", "MK@COCOA", "NYB@SB"),
S5016PanelTarget.AgricultureLivestock => WorldFuture("MK@LC", "MK@FC", "MK@PORK"),
S5016PanelTarget.RawMaterialsCopperIronNickel => WorldFuture("LME@CDY", "MK@IRON", "LME@NDY"),
S5016PanelTarget.RawMaterialsGasPlatinumPalladium => WorldFuture("NYM@NG", "NYM@PL", "NYM@PA"),
S5016PanelTarget.RawMaterialsLeadZincTin => WorldFuture("MK@PDA", "MK@ZDA", "LME@SDY"),
S5016PanelTarget.MajorKospiKosdaqKospi200 => new(PanelSource.MajorIndex200, []),
S5016PanelTarget.MajorKospiKosdaqFutures => new(PanelSource.MajorIndexFutures, []),
S5016PanelTarget.ExpectedKospiKosdaqKospi200 => new(PanelSource.ExpectedIndex200, []),
S5016PanelTarget.ExpectedKospiKosdaqFutures => new(PanelSource.ExpectedIndexFutures, []),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
private static PanelProfile Profile(S50160PanelTarget target) => target switch
{
S50160PanelTarget.Cotton => WorldFuture("MK@COTTON", "NYB@CT"),
S50160PanelTarget.InternationalPreciousMetals => WorldFuture("COM@GC", "COM@SI"),
S50160PanelTarget.DomesticPreciousMetals => WorldFuture("MK@GC", "MK@SI"),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
private static PanelProfile WorldIndex(params string[] symbols) =>
new(PanelSource.WorldIndex, symbols);
private static PanelProfile WorldFuture(params string[] symbols) =>
new(PanelSource.WorldFuture, symbols);
private enum PanelSource
{
WorldIndex,
WorldFuture,
WorldForex,
DomesticExchange,
MajorIndex200,
MajorIndexFutures,
ExpectedIndex200,
ExpectedIndexFutures
}
private sealed record PanelProfile(
PanelSource Source,
IReadOnlyList<string> Symbols,
IReadOnlyList<string>? DisplayNames = null);
}
internal static class PanelQuoteTableMapper
{
private static readonly string[] Columns =
["ORDINAL", "NAME", "PART_NAME", "PRICE", "CHANGE", "RATE", "SIGNAL"];
public static IReadOnlyList<LegacyPanelQuoteData> Map(DataTable? table, int expectedRows)
{
EquityPanelLoaderReader.ExactSchema(table, Columns);
if (table!.Rows.Count != expectedRows)
{
throw EquityPanelLoaderReader.InvalidResult();
}
var result = new LegacyPanelQuoteData[expectedRows];
for (var index = 0; index < expectedRows; index++)
{
var row = table.Rows[index];
if (EquityPanelLoaderReader.Int64(row, "ORDINAL") != index + 1)
{
throw EquityPanelLoaderReader.InvalidResult();
}
var change = EquityPanelLoaderReader.Decimal(row, "CHANGE");
result[index] = new LegacyPanelQuoteData(
EquityPanelLoaderReader.Text(row, "NAME"),
row["PART_NAME"] is DBNull
? null
: EquityPanelLoaderReader.Text(row, "PART_NAME"),
EquityPanelLoaderReader.Decimal(row, "PRICE"),
change,
EquityPanelLoaderReader.Decimal(row, "RATE"),
Signal(EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true)));
}
return result;
}
private static LegacyPanelQuoteSignal Signal(string value) => value switch
{
"+" or "1" or "2" => LegacyPanelQuoteSignal.Positive,
"-" or "4" or "5" => LegacyPanelQuoteSignal.Negative,
"" or "3" => LegacyPanelQuoteSignal.Flat,
_ => throw EquityPanelLoaderReader.InvalidResult()
};
}
public sealed class S6001SceneDataLoader
{
private static readonly string[] Columns =
["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"];
private readonly IDataQueryExecutor _executor;
public S6001SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S6001SceneData> LoadAsync(
S6001QuoteTarget target,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(target))
{
throw EquityPanelLoaderReader.InvalidRequest();
}
var profile = CommodityQuoteQueryFactory.S6001(target);
var spec = CommodityQuoteQueryFactory.Create(profile);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S6001_QUOTE",
spec,
cancellationToken).ConfigureAwait(false);
var row = EquityPanelLoaderReader.SingleRow(table, Columns);
var data = new S6001SceneData(
target,
EquityPanelLoaderReader.Text(row, "TITLE"),
EquityPanelLoaderReader.Decimal(row, "PRICE"),
EquityPanelLoaderReader.Decimal(row, "CHANGE"),
EquityPanelLoaderReader.Decimal(row, "RATE"),
EquityPanelLoaderReader.Direction(row, "SIGNAL"));
_ = new S6001SceneMutationBuilder().Build(data);
return data;
}
}
public enum S8086DiagnosticTarget
{
DubaiCrude,
BrentCrude,
WtiCrude,
Gold,
Silver,
Copper
}
public sealed class S8086DiagnosticSceneDataLoader
{
private static readonly string[] Columns =
["TITLE", "PRICE", "CHANGE", "RATE", "SIGNAL"];
private readonly IDataQueryExecutor _executor;
public S8086DiagnosticSceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S8086SceneData> LoadAsync(
S8086DiagnosticTarget target,
CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(target))
{
throw EquityPanelLoaderReader.InvalidRequest();
}
var profile = CommodityQuoteQueryFactory.S8086(target);
var spec = CommodityQuoteQueryFactory.Create(profile);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S8086_DIAGNOSTIC",
spec,
cancellationToken).ConfigureAwait(false);
var row = EquityPanelLoaderReader.SingleRow(table, Columns);
var data = new S8086SceneData(
EquityPanelLoaderReader.Text(row, "TITLE"),
profile.Unit,
EquityPanelLoaderReader.Double(row, "PRICE"),
EquityPanelLoaderReader.Double(row, "CHANGE"),
EquityPanelLoaderReader.Double(row, "RATE"),
CommodityQuoteQueryFactory.Trend(
EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true)));
_ = new S8086SceneMutationBuilder().Build(data);
return data;
}
}
internal static class CommodityQuoteQueryFactory
{
public static QuoteProfile S6001(S6001QuoteTarget target) => target switch
{
S6001QuoteTarget.DowJones => Index("DJI@DJI"),
S6001QuoteTarget.Nasdaq => Index("NAS@IXIC"),
S6001QuoteTarget.StandardAndPoor500 => Index("SPI@SPX"),
S6001QuoteTarget.Germany => Index("XTR@DAX30"),
S6001QuoteTarget.UnitedKingdom => Index("LNS@FTSE100"),
S6001QuoteTarget.France => Index("PAS@CAC40"),
S6001QuoteTarget.Japan => Index("NII@NI225"),
S6001QuoteTarget.China => Index("SHS@000001"),
S6001QuoteTarget.HongKong => Index("HSI@HSI"),
S6001QuoteTarget.Taiwan => Index("TWS@TI01"),
S6001QuoteTarget.Singapore => Index("SGI@STI"),
S6001QuoteTarget.Thailand => Index("THI@SET"),
S6001QuoteTarget.Philippines => Index("PHI@COMP"),
// Preserve the legacy source selections, including its Malaysia/Indonesia swap.
S6001QuoteTarget.Malaysia => Index("IDI@JKSE"),
S6001QuoteTarget.Indonesia => Index("MYI@KLSE"),
S6001QuoteTarget.DubaiCrude => Future("SPT@DU", CommodityUnit.DollarsPerBarrel),
S6001QuoteTarget.BrentCrude => Future("SPT@EB", CommodityUnit.DollarsPerBarrel),
S6001QuoteTarget.WtiCrude => Future("SPT@CL", CommodityUnit.DollarsPerBarrel),
S6001QuoteTarget.Gold => Future(
"COM@GC",
CommodityUnit.DollarsPerOunce,
absoluteValues: true),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
public static QuoteProfile S8086(S8086DiagnosticTarget target) => target switch
{
S8086DiagnosticTarget.DubaiCrude => Future("SPT@DU", CommodityUnit.DollarsPerBarrel),
S8086DiagnosticTarget.BrentCrude => Future("SPT@EB", CommodityUnit.DollarsPerBarrel),
S8086DiagnosticTarget.WtiCrude => Future("SPT@CL", CommodityUnit.DollarsPerBarrel),
S8086DiagnosticTarget.Gold => Future(
"COM@GC", CommodityUnit.DollarsPerOunce, absoluteValues: true),
S8086DiagnosticTarget.Silver => Future(
"COM@SI", CommodityUnit.DollarsPerOunce, absoluteValues: true),
S8086DiagnosticTarget.Copper => Future(
"LME@CDY", CommodityUnit.DollarsPerOunce, absoluteValues: true),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
public static DataQuerySpec Create(QuoteProfile profile)
{
var (master, quote, signal) = profile.Source switch
{
QuoteSource.WorldIndex => (
"t_world_ix_eq_master",
"t_world_ix_eq_sise",
"b.f_sign"),
QuoteSource.WorldFuture => (
"t_world_future_master",
"t_world_future_sise",
"CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+' " +
"WHEN TO_NUMBER(b.f_diff) < 0 THEN '-' ELSE ' ' END"),
_ => throw EquityPanelLoaderReader.InvalidRequest()
};
var priceExpression = profile.AbsoluteValues
? "ABS(ROUND(b.f_last, 4))"
: "ROUND(b.f_last, 4)";
var changeExpression = profile.AbsoluteValues
? "ABS(ROUND(b.f_diff, 4))"
: "ROUND(b.f_diff, 4)";
var sql = $"""
SELECT
a.f_input_name TITLE,
{priceExpression} PRICE,
{changeExpression} CHANGE,
ROUND(b.f_rate, 4) RATE,
{signal} SIGNAL
FROM {master} a, {quote} b
WHERE a.f_symb = b.f_symb
AND a.f_symb = :symbol
""";
return EquityPanelLoaderReader.Spec(
DataSourceKind.Oracle,
sql,
new DataQueryParameter("symbol", profile.Symbol, DbType.String));
}
public static SceneMarketTrend Trend(string value) => value switch
{
"+" or "1" or "2" => SceneMarketTrend.Up,
"-" or "4" or "5" => SceneMarketTrend.Down,
"" or "3" => SceneMarketTrend.Flat,
_ => throw EquityPanelLoaderReader.InvalidResult()
};
private static QuoteProfile Index(string symbol) =>
new(QuoteSource.WorldIndex, symbol, CommodityUnit.DollarsPerOunce, false);
private static QuoteProfile Future(
string symbol,
CommodityUnit unit,
bool absoluteValues = false) =>
new(QuoteSource.WorldFuture, symbol, unit, absoluteValues);
internal enum QuoteSource
{
WorldIndex,
WorldFuture
}
internal sealed record QuoteProfile(
QuoteSource Source,
string Symbol,
CommodityUnit Unit,
bool AbsoluteValues);
}
public sealed class S8067SceneDataLoader
{
private static readonly string[] Columns =
["ORDINAL", "MARKET_CODE", "CURRENT_PRICE", "NET_CHANGE", "RATE", "SIGNAL"];
private static readonly (string Code, string Symbol)[] Markets =
[
("CHINA", "SHS@000001"),
("HONG_KONG", "HSI@HSI"),
("JAPAN", "NII@NI225"),
("TAIWAN", "TWS@TI01"),
("DOW", "DJI@DJI"),
("NASDAQ", "NAS@IXIC"),
("SP", "SPI@SPX"),
("UNITED_KINGDOM", "LNS@FTSE100"),
("GERMANY", "XTR@DAX30"),
("FRANCE", "PAS@CAC40")
];
private readonly IDataQueryExecutor _executor;
public S8067SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S8067SceneData> LoadAsync(CancellationToken cancellationToken = default)
{
var spec = CreateSpec();
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"S8067_WORLD_MAP",
spec,
cancellationToken).ConfigureAwait(false);
EquityPanelLoaderReader.ExactSchema(table, Columns);
if (table.Rows.Count != 11)
{
throw EquityPanelLoaderReader.InvalidResult();
}
var quotes = new WorldMarketQuoteData[11];
for (var index = 0; index < quotes.Length; index++)
{
var row = table.Rows[index];
if (EquityPanelLoaderReader.Int64(row, "ORDINAL") != index + 1)
{
throw EquityPanelLoaderReader.InvalidResult();
}
quotes[index] = new WorldMarketQuoteData(
Market(EquityPanelLoaderReader.Text(row, "MARKET_CODE")),
EquityPanelLoaderReader.Double(row, "CURRENT_PRICE"),
EquityPanelLoaderReader.Double(row, "NET_CHANGE"),
EquityPanelLoaderReader.Double(row, "RATE"),
CommodityQuoteQueryFactory.Trend(
EquityPanelLoaderReader.Text(row, "SIGNAL", allowNull: true)));
}
var data = new S8067SceneData(quotes);
_ = new S8067SceneMutationBuilder().Build(data);
return data;
}
private static DataQuerySpec CreateSpec()
{
const string korea = """
SELECT
1 ORDINAL,
'KOREA' MARKET_CODE,
ROUND(f_part_idx / 100, 2) CURRENT_PRICE,
ROUND(f_part_chg / 100, 2) NET_CHANGE,
ROUND((f_part_chg / DECODE(
f_chg_type,
'+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg,
1)) * 100, 2) RATE,
f_chg_type SIGNAL
FROM t_index
WHERE f_part_code = :partCode
""";
var world = Markets.Select((market, zeroBased) =>
{
var ordinal = zeroBased + 2;
return $"""
SELECT
{ordinal} ORDINAL,
'{market.Code}' MARKET_CODE,
TO_NUMBER(f_last) CURRENT_PRICE,
TO_NUMBER(f_diff) NET_CHANGE,
TO_NUMBER(f_rate) RATE,
f_sign SIGNAL
FROM t_world_ix_eq_sise
WHERE f_symb = :symbol{ordinal}
""";
});
var sql = korea + "\nUNION ALL\n" +
string.Join("\nUNION ALL\n", world) + "\nORDER BY ORDINAL";
var parameters = new List<DataQueryParameter>
{
new("partCode", "001", DbType.String)
};
parameters.AddRange(Markets.Select((market, zeroBased) =>
new DataQueryParameter(
"symbol" + (zeroBased + 2),
market.Symbol,
DbType.String)));
return EquityPanelLoaderReader.Spec(
DataSourceKind.Oracle,
sql,
parameters.ToArray());
}
private static WorldMarket Market(string code) => code switch
{
"KOREA" => WorldMarket.Korea,
"CHINA" => WorldMarket.China,
"HONG_KONG" => WorldMarket.HongKong,
"JAPAN" => WorldMarket.Japan,
"TAIWAN" => WorldMarket.Taiwan,
"DOW" => WorldMarket.Dow,
"NASDAQ" => WorldMarket.Nasdaq,
"SP" => WorldMarket.StandardAndPoor,
"UNITED_KINGDOM" => WorldMarket.UnitedKingdom,
"GERMANY" => WorldMarket.Germany,
"FRANCE" => WorldMarket.France,
_ => throw EquityPanelLoaderReader.InvalidResult()
};
}

View File

@@ -0,0 +1,388 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum PagedQuoteBranch
{
Domestic,
Nxt,
Theme,
NxtTheme,
Expert,
Vi
}
public enum PagedQuoteHeaderStyle
{
StandardWon,
IndustryPoints,
ExpertReturns
}
public enum PagedQuoteTitleStyle
{
Exact,
ExpectedExecution
}
public enum NxtMarketSession
{
NotApplicable,
PreMarket,
AfterMarket
}
public sealed record PagedQuoteRowData(
string Name,
decimal PrimaryValue,
decimal SecondaryValue,
double Rate,
ScenePriceDirection Direction);
/// <summary>
/// Trusted page input shared by the legacy 5-, 6-, and 12-row quote scenes.
/// PageNumberOneBased follows ScenePaging; Rows always contains the complete logical result.
/// </summary>
public sealed record PagedQuoteSceneData(
PagedQuoteBranch Branch,
PagedQuoteHeaderStyle HeaderStyle,
PagedQuoteTitleStyle TitleStyle,
NxtMarketSession NxtSession,
string Title,
IReadOnlyList<PagedQuoteRowData> Rows,
int PageNumberOneBased) : ILegacySceneData;
public sealed class S5074SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
{
public string BuilderKey => "s5074";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Five, useWideRateVariants: false);
}
public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
{
public string BuilderKey => "s5077";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Six, useWideRateVariants: true);
}
public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder<PagedQuoteSceneData>
{
public string BuilderKey => "s5088";
public IReadOnlyList<PlayoutMutation> Build(PagedQuoteSceneData data) =>
PagedQuoteMutations.Build(data, ScenePageSize.Twelve, useWideRateVariants: true);
}
internal static class PagedQuoteMutations
{
private static readonly string[] DirectionNames =
["upup", "up", "flat", "down", "downdown"];
public static IReadOnlyList<PlayoutMutation> Build(
PagedQuoteSceneData data,
ScenePageSize pageSize,
bool useWideRateVariants)
{
ArgumentNullException.ThrowIfNull(data);
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
ValidatePresentation(data);
var rows = LegacySceneBuilderGuard.NotNull(data.Rows, nameof(data.Rows));
var windowResult = ScenePaging.GetWindow(
rows.Count,
pageSize,
data.PageNumberOneBased);
if (!windowResult.IsValid || windowResult.Window is null)
{
throw new LegacySceneDataException(
"The requested quote page is outside the accessible 20-page range.");
}
var window = windowResult.Window;
var selectedRows = new PagedQuoteRowData[window.ItemCount];
for (var index = 0; index < window.ItemCount; index++)
{
var sourceIndex = window.OffsetZeroBased + index;
var row = LegacySceneBuilderGuard.NotNull(
rows[sourceIndex],
$"Rows[{sourceIndex}]");
if (!double.IsFinite(row.Rate))
{
throw new LegacySceneDataException(
$"Scene data field 'Rows[{sourceIndex}].Rate' must be finite.");
}
if (data.HeaderStyle != PagedQuoteHeaderStyle.IndustryPoints &&
(decimal.Truncate(row.PrimaryValue) != row.PrimaryValue ||
decimal.Truncate(row.SecondaryValue) != row.SecondaryValue))
{
throw new LegacySceneDataException(
$"Scene data field 'Rows[{sourceIndex}]' requires whole-won values.");
}
selectedRows[index] = row with
{
Name = LegacySceneBuilderGuard.Text(
row.Name,
$"Rows[{sourceIndex}].Name")
};
}
var size = (int)pageSize;
var estimatedPerRow = useWideRateVariants ? 27 : 19;
var mutations = new List<PlayoutMutation>(14 + (size * estimatedPerRow))
{
// Every legacy constructor hides the market mark before entering its branch.
new PlayoutSetVisible("mart", false)
};
for (var slot = 1; slot <= size; slot++)
{
mutations.Add(new PlayoutSetVisible("row" + slot, false));
}
mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle)));
AddMarketMark(mutations, data.Branch, data.NxtSession);
AddHeader(mutations, data.HeaderStyle);
for (var slot = 1; slot <= size; slot++)
{
if (slot <= selectedRows.Length)
{
AddPopulatedRow(
mutations,
slot,
selectedRows[slot - 1],
useWideRateVariants,
usePointValues: data.HeaderStyle == PagedQuoteHeaderStyle.IndustryPoints);
}
else
{
AddClearedRow(mutations, slot, useWideRateVariants);
}
}
return mutations;
}
private static void ValidatePresentation(PagedQuoteSceneData data)
{
if (!Enum.IsDefined(data.Branch) || !Enum.IsDefined(data.HeaderStyle) ||
!Enum.IsDefined(data.TitleStyle) || !Enum.IsDefined(data.NxtSession))
{
throw new LegacySceneDataException("Unknown paged quote presentation value.");
}
var isNxt = data.Branch is PagedQuoteBranch.Nxt or PagedQuoteBranch.NxtTheme;
if (isNxt != (data.NxtSession != NxtMarketSession.NotApplicable))
{
throw new LegacySceneDataException(
"Only NXT branches can select a pre-market or after-market mark.");
}
if ((data.Branch == PagedQuoteBranch.Expert) !=
(data.HeaderStyle == PagedQuoteHeaderStyle.ExpertReturns))
{
throw new LegacySceneDataException(
"The expert branch requires the expert return columns.");
}
if (data.HeaderStyle == PagedQuoteHeaderStyle.IndustryPoints &&
data.Branch != PagedQuoteBranch.Domestic)
{
throw new LegacySceneDataException(
"Industry point columns are available only to the domestic branch.");
}
}
private static string BuildTitle(string title, PagedQuoteTitleStyle style) => style switch
{
PagedQuoteTitleStyle.Exact => title,
PagedQuoteTitleStyle.ExpectedExecution => title + " \uC608\uC0C1 \uCCB4\uACB0\uAC00",
_ => throw new LegacySceneDataException("Unknown paged quote title style.")
};
private static void AddMarketMark(
ICollection<PlayoutMutation> mutations,
PagedQuoteBranch branch,
NxtMarketSession session)
{
if (branch is not (PagedQuoteBranch.Nxt or PagedQuoteBranch.NxtTheme))
{
mutations.Add(new PlayoutSetVisible("mart", false));
return;
}
var relativeAsset = session switch
{
NxtMarketSession.PreMarket => "Images\\\uD504\uB9AC\uB9C8\uCF13.png",
NxtMarketSession.AfterMarket => "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png",
_ => throw new LegacySceneDataException("An NXT market session is required.")
};
mutations.Add(new PlayoutSetAssetValue("mart", relativeAsset));
mutations.Add(new PlayoutSetVisible("mart", true));
}
private static void AddHeader(
ICollection<PlayoutMutation> mutations,
PagedQuoteHeaderStyle style)
{
var values = style switch
{
PagedQuoteHeaderStyle.StandardWon =>
(Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uD604\uC7AC\uAC00", Column2: "\uB4F1\uB77D\uAC00", Column3: "\uB4F1\uB77D\uB960"),
PagedQuoteHeaderStyle.IndustryPoints =>
(Unit: "\uB2E8\uC704 : p", Column1: "\uD604\uC7AC", Column2: "\uB4F1\uB77D", Column3: "\uB4F1\uB77D\uB960"),
PagedQuoteHeaderStyle.ExpertReturns =>
(Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uB9E4\uC218\uAC00", Column2: "\uD604\uC7AC\uAC00", Column3: "\uC218\uC775\uB960"),
_ => throw new LegacySceneDataException("Unknown paged quote header style.")
};
mutations.Add(new PlayoutSetValue("unit", values.Unit));
mutations.Add(new PlayoutSetValue("column1", values.Column1));
mutations.Add(new PlayoutSetValue("column2", values.Column2));
mutations.Add(new PlayoutSetValue("column3", values.Column3));
}
private static void AddPopulatedRow(
ICollection<PlayoutMutation> mutations,
int slot,
PagedQuoteRowData row,
bool useWideRateVariants,
bool usePointValues)
{
mutations.Add(new PlayoutSetVisible("row" + slot, true));
mutations.Add(new PlayoutSetValue("title" + slot, row.Name));
mutations.Add(new PlayoutSetValue(
"price" + slot,
FormatNumber(row.PrimaryValue, usePointValues)));
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetValue(
$"changePrice{slot}_{variant}",
FormatNumber(row.SecondaryValue, usePointValues)));
}
var rate = row.Rate.ToString("##0.00", CultureInfo.InvariantCulture);
if (useWideRateVariants)
{
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetValue($"rate{slot}_{variant}", rate));
}
}
else
{
mutations.Add(new PlayoutSetValue("rate" + slot, rate));
}
AddDirectionHidden(mutations, slot);
var selectedVariant = row.Direction switch
{
ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1,
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2,
ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3,
_ => throw new LegacySceneDataException("Unknown price direction.")
};
AddVariantVisibility(mutations, slot, selectedVariant, useWideRateVariants);
var activeDirection = row.Direction switch
{
ScenePriceDirection.LimitUp => "upup",
ScenePriceDirection.Up => "up",
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => "flat",
ScenePriceDirection.Down => "down",
ScenePriceDirection.LimitDown => "downdown",
_ => throw new LegacySceneDataException("Unknown price direction.")
};
mutations.Add(new PlayoutSetVisible(activeDirection + slot, true));
}
private static void AddClearedRow(
ICollection<PlayoutMutation> mutations,
int slot,
bool useWideRateVariants)
{
// The original hides all row groups first. Explicit value/variant clearing is the
// approved partial-page correction required for in-place GetPlayingScene updates.
mutations.Add(new PlayoutSetVisible("row" + slot, false));
mutations.Add(new PlayoutSetValue("title" + slot, string.Empty));
mutations.Add(new PlayoutSetValue("price" + slot, string.Empty));
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetValue($"changePrice{slot}_{variant}", string.Empty));
}
if (useWideRateVariants)
{
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetValue($"rate{slot}_{variant}", string.Empty));
}
}
else
{
mutations.Add(new PlayoutSetValue("rate" + slot, string.Empty));
}
AddDirectionHidden(mutations, slot);
AddVariantVisibility(mutations, slot, selectedVariant: 0, useWideRateVariants);
}
private static void AddDirectionHidden(
ICollection<PlayoutMutation> mutations,
int slot)
{
foreach (var name in DirectionNames)
{
mutations.Add(new PlayoutSetVisible(name + slot, false));
}
}
private static void AddVariantVisibility(
ICollection<PlayoutMutation> mutations,
int slot,
int selectedVariant,
bool useWideRateVariants)
{
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"bg{slot}_{variant}",
variant == selectedVariant));
}
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"changePrice{slot}_{variant}",
variant == selectedVariant));
}
if (!useWideRateVariants)
{
return;
}
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"rate{slot}_{variant}",
variant == selectedVariant));
}
for (var variant = 1; variant <= 3; variant++)
{
mutations.Add(new PlayoutSetVisible(
$"percent{slot}_{variant}",
variant == selectedVariant));
}
}
private static string FormatNumber(decimal value, bool usePointValues) =>
value.ToString(
usePointValues ? "#,##0.00" : "#,##0",
CultureInfo.InvariantCulture);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
#nullable enable
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed class S5006NxtSceneDataLoader
{
private const string KospiQuery = """
SELECT a.f_stock_code stock_code, b.f_stock_name stock_name,
a.f_curr_price curr_price,
CASE WHEN a.f_chg_type = '1' THEN ''
WHEN a.f_chg_type = '2' THEN ''
WHEN a.f_chg_type = '3' THEN ''
WHEN a.f_chg_type = '4' THEN ''
WHEN a.f_chg_type = '5' THEN '' END chg_type,
a.f_net_chg net_chg,
ROUND((a.f_net_chg / CASE WHEN a.f_chg_type IN ('1','2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4','5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) rate,
a.f_init_price init_price,
IF(c.f_final_price = 0,
ROUND(((a.f_init_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_init_price - c.f_final_price) / c.f_final_price) * 100, 2)) init_rate,
a.f_high_price high_price,
IF(c.f_final_price = 0,
ROUND(((a.f_high_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_high_price - c.f_final_price) / c.f_final_price) * 100, 2)) high_rate,
a.f_low_price low_price,
IF(c.f_final_price = 0,
ROUND(((a.f_low_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_low_price - c.f_final_price) / c.f_final_price) * 100, 2)) low_rate
FROM n_online a
JOIN n_stock b ON a.f_stock_code = b.f_stock_code
JOIN n_batch_his c ON a.f_stock_code = c.f_stock_code
WHERE a.f_curr_price <> 0
AND c.f_data_day = (SELECT MAX(h.f_data_day) FROM n_batch_his h WHERE h.f_stock_code = a.f_stock_code)
AND a.f_stock_code = @stockCode
""";
private const string KosdaqQuery = """
SELECT a.f_stock_code stock_code, b.f_stock_name stock_name,
a.f_curr_price curr_price,
CASE WHEN a.f_chg_type = '1' THEN ''
WHEN a.f_chg_type = '2' THEN ''
WHEN a.f_chg_type = '3' THEN ''
WHEN a.f_chg_type = '4' THEN ''
WHEN a.f_chg_type = '5' THEN '' END chg_type,
a.f_net_chg net_chg,
ROUND((a.f_net_chg / CASE WHEN a.f_chg_type IN ('1','2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4','5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) rate,
a.f_init_price init_price,
IF(c.f_final_price = 0,
ROUND(((a.f_init_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_init_price - c.f_final_price) / c.f_final_price) * 100, 2)) init_rate,
a.f_high_price high_price,
IF(c.f_final_price = 0,
ROUND(((a.f_high_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_high_price - c.f_final_price) / c.f_final_price) * 100, 2)) high_rate,
a.f_low_price low_price,
IF(c.f_final_price = 0,
ROUND(((a.f_low_price - c.f_base_price) / c.f_base_price) * 100, 2),
ROUND(((a.f_low_price - c.f_final_price) / c.f_final_price) * 100, 2)) low_rate
FROM n_kosdaq_online a
JOIN n_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
JOIN n_kosdaq_batch_his c ON a.f_stock_code = c.f_stock_code
WHERE a.f_curr_price <> 0
AND c.f_data_day = (SELECT MAX(h.f_data_day) FROM n_kosdaq_batch_his h WHERE h.f_stock_code = a.f_stock_code)
AND a.f_stock_code = @stockCode
""";
private readonly IDataQueryExecutor _executor;
public S5006NxtSceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5006SceneData> LoadAsync(
LegacyDomesticEquityMarket market,
string stockCode,
CancellationToken cancellationToken = default)
{
var query = market switch
{
LegacyDomesticEquityMarket.NxtKospi => KospiQuery,
LegacyDomesticEquityMarket.NxtKosdaq => KosdaqQuery,
_ => throw new LegacySceneDataException("The NXT scene loader requires an NXT market.")
};
stockCode = RequireStockCode(stockCode);
var spec = new DataQuerySpec(
query,
[new DataQueryParameter("stockCode", stockCode, DbType.String)]);
var table = await _executor.ExecuteAsync(
DataSourceKind.MariaDb,
"SCENE_5006_NXT",
spec,
cancellationToken).ConfigureAwait(false);
var row = SceneDataTableReader.SingleRow(
table,
"STOCK_NAME", "CURR_PRICE", "CHG_TYPE", "NET_CHG", "RATE",
"INIT_PRICE", "INIT_RATE", "HIGH_PRICE", "HIGH_RATE", "LOW_PRICE", "LOW_RATE");
var directionText = SceneDataTableReader.RequiredString(row, "CHG_TYPE");
var direction = LegacyPriceDirectionMutations.Parse(directionText);
if (direction == ScenePriceDirection.Unknown)
{
throw new LegacySceneDataException("The NXT quote returned an unknown direction.");
}
return new S5006SceneData(
market,
SceneDataTableReader.RequiredString(row, "STOCK_NAME"),
direction,
Int64(row, "CURR_PRICE"),
Int64(row, "NET_CHG"),
Decimal(row, "RATE"),
Int64(row, "INIT_PRICE"),
Decimal(row, "INIT_RATE"),
Int64(row, "HIGH_PRICE"),
Decimal(row, "HIGH_RATE"),
Int64(row, "LOW_PRICE"),
Decimal(row, "LOW_RATE"));
}
private static string RequireStockCode(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 32 ||
value.Any(character => !char.IsAsciiLetterOrDigit(character)))
{
throw new LegacySceneDataException("The NXT stock code is invalid.");
}
return value;
}
private static long Int64(DataRow row, string column)
{
try
{
return checked(Convert.ToInt64(RequiredValue(row, column), CultureInfo.InvariantCulture));
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
throw new LegacySceneDataException("The NXT quote returned an invalid integer.");
}
}
private static decimal Decimal(DataRow row, string column)
{
try
{
return Convert.ToDecimal(RequiredValue(row, column), CultureInfo.InvariantCulture);
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
throw new LegacySceneDataException("The NXT quote returned an invalid decimal.");
}
}
private static object RequiredValue(DataRow row, string column) => row[column] is { } value && value is not DBNull
? value
: throw new LegacySceneDataException("The NXT quote returned a required null value.");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
internal sealed record ParameterizedSceneQuery(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec);
internal static class ParameterizedMarketSceneQueries
{
public static ParameterizedSceneQuery WorldFuture(string symbol) =>
Oracle(
"SCENE_WORLD_FUTURE_QUOTE",
WorldFutureQuery,
new DataQueryParameter("symbol", symbol, DbType.String));
public static ParameterizedSceneQuery MarketQuote(
LegacyMarketQuoteTarget target,
bool expected)
{
S5001SceneMutationBuilder.RequireDefined(target, nameof(target));
return expected ? ExpectedMarketQuote(target) : CurrentMarketQuote(target);
}
public static ParameterizedSceneQuery FuturesDetail() =>
Oracle(
"SCENE_FUTURES_DETAIL",
CurrentFuturesDetailQuery,
new DataQueryParameter("stockSequence", 1, DbType.Int32),
new DataQueryParameter("monthCategory", "1", DbType.String));
public static ParameterizedSceneQuery DomesticIndustry(
LegacyDomesticEquityMarket market,
string industryName) => market switch
{
LegacyDomesticEquityMarket.Kospi => Oracle(
"SCENE_KOSPI_INDUSTRY",
KospiIndustryQuery,
new DataQueryParameter("industryName", industryName, DbType.String)),
LegacyDomesticEquityMarket.Kosdaq => Oracle(
"SCENE_KOSDAQ_INDUSTRY",
KosdaqIndustryQuery,
new DataQueryParameter("industryName", industryName, DbType.String)),
LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq =>
throw new LegacySceneDataException(
"The original DataManager has no NXT industry request implementation."),
_ => throw new LegacySceneDataException("Unsupported domestic industry market.")
};
public static ParameterizedSceneQuery ForeignIndustry(string industryName) =>
Oracle(
"SCENE_FOREIGN_INDUSTRY",
ForeignIndustryQuery,
new DataQueryParameter("industryName", industryName, DbType.String));
public static ParameterizedSceneQuery ForeignStock(string stockName) =>
Oracle(
"SCENE_FOREIGN_STOCK",
ForeignStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String));
public static ParameterizedSceneQuery CurrentStock(
LegacyDomesticEquityMarket market,
string stockName,
bool halted)
{
if (halted)
{
return market switch
{
LegacyDomesticEquityMarket.Kospi => Oracle(
"SCENE_HALTED_KOSPI_STOCK",
HaltedKospiStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.Kosdaq => Oracle(
"SCENE_HALTED_KOSDAQ_STOCK",
HaltedKosdaqStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
_ => throw new LegacySceneDataException("Unsupported halted stock market.")
};
}
return market switch
{
LegacyDomesticEquityMarket.Kospi => Oracle(
"SCENE_KOSPI_STOCK",
KospiStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.Kosdaq => Oracle(
"SCENE_KOSDAQ_STOCK",
KosdaqStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.NxtKospi => Maria(
"SCENE_NXT_KOSPI_STOCK",
NxtKospiStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.NxtKosdaq => Maria(
"SCENE_NXT_KOSDAQ_STOCK",
NxtKosdaqStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
_ => throw new LegacySceneDataException("Unsupported current stock market.")
};
}
public static ParameterizedSceneQuery ExpectedStock(
LegacyDomesticEquityMarket market,
string stockName) => market switch
{
LegacyDomesticEquityMarket.Kospi => Oracle(
"SCENE_EXPECTED_KOSPI_STOCK",
ExpectedKospiStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.Kosdaq => Oracle(
"SCENE_EXPECTED_KOSDAQ_STOCK",
ExpectedKosdaqStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
_ => throw new LegacySceneDataException("Unsupported expected stock market.")
};
public static ParameterizedSceneQuery AfterHoursStock(
LegacyDomesticEquityMarket market,
string stockName) => market switch
{
LegacyDomesticEquityMarket.Kospi => Oracle(
"SCENE_AFTER_HOURS_KOSPI_STOCK",
AfterHoursKospiStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
LegacyDomesticEquityMarket.Kosdaq => Oracle(
"SCENE_AFTER_HOURS_KOSDAQ_STOCK",
AfterHoursKosdaqStockQuery,
new DataQueryParameter("stockName", stockName, DbType.String)),
_ => throw new LegacySceneDataException("Unsupported after-hours stock market.")
};
public static ParameterizedSceneQuery Sectors(S8001Market market) => market switch
{
S8001Market.Kospi => Oracle(
"SCENE_8001_KOSPI_SECTORS",
KospiSectorsQuery),
S8001Market.Kosdaq => Oracle(
"SCENE_8001_KOSDAQ_SECTORS",
KosdaqSectorsQuery,
KosdaqSectorParameters()),
_ => throw new LegacySceneDataException("Unsupported s8001 market.")
};
private static ParameterizedSceneQuery CurrentMarketQuote(LegacyMarketQuoteTarget target) => target switch
{
LegacyMarketQuoteTarget.Kospi => DomesticIndex(KospiIndexQuery, "SCENE_KOSPI_INDEX", "001"),
LegacyMarketQuoteTarget.Kosdaq => DomesticIndex(KosdaqIndexQuery, "SCENE_KOSDAQ_INDEX", "001"),
LegacyMarketQuoteTarget.Kospi200 => DomesticIndex(Kospi200IndexQuery, "SCENE_KOSPI200_INDEX", "029"),
LegacyMarketQuoteTarget.Krx100 => DomesticIndex(Krx100IndexQuery, "SCENE_KRX100_INDEX", "043"),
LegacyMarketQuoteTarget.Futures => Oracle(
"SCENE_FUTURES_INDEX",
CurrentFuturesQuoteQuery,
new DataQueryParameter("stockSequence", 1, DbType.Int32),
new DataQueryParameter("monthCategory", "1", DbType.String)),
LegacyMarketQuoteTarget.WonDollar => Exchange("원/달러", "A09"),
LegacyMarketQuoteTarget.WonYen => Exchange("원/엔", "A10"),
LegacyMarketQuoteTarget.WonYuan => Exchange("원/위엔", "A90"),
LegacyMarketQuoteTarget.WonEuro => Exchange("원/유로", "A91"),
_ => ForeignIndex(ForeignSymbol(target))
};
private static ParameterizedSceneQuery ExpectedMarketQuote(LegacyMarketQuoteTarget target) => target switch
{
LegacyMarketQuoteTarget.Kospi => DomesticIndex(ExpectedKospiIndexQuery, "SCENE_EXPECTED_KOSPI_INDEX", "001"),
LegacyMarketQuoteTarget.Kosdaq => DomesticIndex(ExpectedKosdaqIndexQuery, "SCENE_EXPECTED_KOSDAQ_INDEX", "001"),
LegacyMarketQuoteTarget.Kospi200 => DomesticIndex(ExpectedKospi200IndexQuery, "SCENE_EXPECTED_KOSPI200_INDEX", "029"),
LegacyMarketQuoteTarget.Krx100 => DomesticIndex(ExpectedKrx100IndexQuery, "SCENE_EXPECTED_KRX100_INDEX", "043"),
LegacyMarketQuoteTarget.Futures => Oracle(
"SCENE_EXPECTED_FUTURES_INDEX",
ExpectedFuturesQuoteQuery,
new DataQueryParameter("stockSequence", 1, DbType.Int32),
new DataQueryParameter("monthCategory", "1", DbType.String)),
_ => throw new LegacySceneDataException("Expected data is unavailable for this market target.")
};
private static ParameterizedSceneQuery DomesticIndex(
string sql,
string tableName,
string partCode) => Oracle(
tableName,
sql,
new DataQueryParameter("partCode", partCode, DbType.String));
private static ParameterizedSceneQuery Exchange(string displayName, string inputCode) => Oracle(
"SCENE_EXCHANGE_RATE",
ExchangeQuery,
new DataQueryParameter("displayName", displayName, DbType.String),
new DataQueryParameter("inputCode", inputCode, DbType.String));
private static ParameterizedSceneQuery ForeignIndex(string symbol) => Oracle(
"SCENE_FOREIGN_INDEX",
ForeignIndexQuery,
new DataQueryParameter("symbol", symbol, DbType.String));
private static string ForeignSymbol(LegacyMarketQuoteTarget target) => target switch
{
LegacyMarketQuoteTarget.Dow => "DJI@DJI",
LegacyMarketQuoteTarget.Nasdaq => "NAS@IXIC",
LegacyMarketQuoteTarget.Sp500 => "SPI@SPX",
LegacyMarketQuoteTarget.GermanyDax => "XTR@DAX30",
LegacyMarketQuoteTarget.UnitedKingdomFtse => "LNS@FTSE100",
LegacyMarketQuoteTarget.FranceCac => "PAS@CAC40",
LegacyMarketQuoteTarget.Nikkei => "NII@NI225",
LegacyMarketQuoteTarget.ShanghaiComposite => "SHS@000001",
LegacyMarketQuoteTarget.HangSeng => "HSI@HSI",
LegacyMarketQuoteTarget.TaiwanWeighted => "TWS@TI01",
LegacyMarketQuoteTarget.SingaporeStraitsTimes => "SGI@STI",
LegacyMarketQuoteTarget.ThailandSet => "THI@SET",
LegacyMarketQuoteTarget.PhilippinesComposite => "PHI@COMP",
// Preserve the original request mapping, including its historical symbol labels.
LegacyMarketQuoteTarget.MalaysiaKlse => "MYI@JKSE",
LegacyMarketQuoteTarget.IndonesiaComposite => "IDI@KLSE",
_ => throw new LegacySceneDataException("Unsupported foreign market target.")
};
private static ParameterizedSceneQuery Oracle(
string tableName,
string sql,
params DataQueryParameter[] parameters) => new(
DataSourceKind.Oracle,
tableName,
new DataQuerySpec(sql, parameters));
private static ParameterizedSceneQuery Maria(
string tableName,
string sql,
params DataQueryParameter[] parameters) => new(
DataSourceKind.MariaDb,
tableName,
new DataQuerySpec(sql, parameters));
private static DataQueryParameter[] KosdaqSectorParameters() =>
[
StringParameter("sector01", "기계장비"),
StringParameter("sector02", "IT부품"),
StringParameter("sector03", "운송"),
StringParameter("sector04", "종이목재"),
StringParameter("sector05", "반도체"),
StringParameter("sector06", "인터넷")
];
private static DataQueryParameter StringParameter(string name, string value) =>
new(name, value, DbType.String);
private const string WorldFutureQuery = """
SELECT a.f_input_name TITLE,
ABS(ROUND(b.f_last, 2)) PRICE,
CASE WHEN TO_NUMBER(b.f_diff) > 0 THEN '+'
WHEN TO_NUMBER(b.f_diff) < 0 THEN '-'
ELSE '' END DIRECTION,
ABS(ROUND(b.f_diff, 2)) NET_CHANGE,
ROUND(b.f_rate, 2) RATE
FROM t_world_future_master a
JOIN t_world_future_sise b ON a.f_symb = b.f_symb
WHERE a.f_symb = :symbol
FETCH FIRST 2 ROWS ONLY
""";
private const string KospiIndexQuery = """
SELECT '' TITLE,
ROUND(f_part_idx / 100, 2) PRICE,
f_chg_type DIRECTION,
ROUND(f_part_chg / 100, 2) NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string KosdaqIndexQuery = """
SELECT '' TITLE,
ROUND(f_part_idx / 100, 2) PRICE,
f_chg_type DIRECTION,
ROUND(f_part_chg / 100, 2) NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_kosdaq_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string Kospi200IndexQuery = """
SELECT '200' TITLE,
f_part_idx / 100 PRICE,
f_chg_type DIRECTION,
f_part_chg / 100 NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_200_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string Krx100IndexQuery = """
SELECT 'KRX100' TITLE,
f_part_idx / 100 PRICE,
f_chg_type DIRECTION,
f_part_chg / 100 NET_CHANGE,
ROUND(f_part_chg / DECODE(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100),
'-', (f_part_idx / 100) + (f_part_chg / 100), 1), 2) RATE
FROM t_krx100_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedKospiIndexQuery = """
SELECT ' ' TITLE,
ROUND(f_part_idx / 100, 2) PRICE,
f_chg_type DIRECTION,
ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_fo_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedKosdaqIndexQuery = """
SELECT ' ' TITLE,
ROUND(f_part_idx / 100, 2) PRICE,
f_chg_type DIRECTION,
ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_kosdaq_fo_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedKospi200IndexQuery = """
SELECT '200 ' TITLE,
f_part_idx / 100 PRICE,
f_chg_type DIRECTION,
ABS(ROUND(f_part_chg / 100, 2)) NET_CHANGE,
ROUND((f_part_chg / DECODE(f_chg_type, '+', f_part_idx - f_part_chg,
'-', f_part_idx + f_part_chg, 1)) * 100, 2) RATE
FROM t_200_fo_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedKrx100IndexQuery = """
SELECT 'KRX100 ' TITLE,
f_part_idx / 100 PRICE,
f_chg_type DIRECTION,
f_part_chg / 100 NET_CHANGE,
ROUND(f_part_chg / DECODE(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100),
'-', (f_part_idx / 100) + (f_part_chg / 100), 1), 2) RATE
FROM t_krx100_fo_index
WHERE f_part_code = :partCode
FETCH FIRST 2 ROWS ONLY
""";
private const string CurrentFuturesQuoteQuery = """
SELECT '' TITLE,
DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) PRICE,
DECODE(a.f_curr_price, 0, '',
DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), 1, '+', -1, '-', 0, '')) DIRECTION,
ABS(DECODE(a.f_curr_price, 0, 0,
(a.f_curr_price - b.f_jun_last_price * 100) / 100)) NET_CHANGE,
ABS(ROUND(DECODE(a.f_curr_price, 0, 0,
(a.f_curr_price - b.f_jun_last_price * 100) / 100) /
DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100),
1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) -
DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100),
-1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) +
DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), 1), 2)) RATE
FROM t_sunmul_online a
JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code
WHERE a.f_stock_seq = :stockSequence
AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day)
AND b.f_month_gubun = :monthCategory
FETCH FIRST 2 ROWS ONLY
""";
private const string CurrentFuturesDetailQuery = """
SELECT '' TITLE,
DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) PRICE,
DECODE(a.f_curr_price, 0, '',
DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100), 1, '+', -1, '-', 0, '')) DIRECTION,
ABS(DECODE(a.f_curr_price, 0, 0,
(a.f_curr_price - b.f_jun_last_price * 100) / 100)) NET_CHANGE,
ABS(ROUND(DECODE(a.f_curr_price, 0, 0,
(a.f_curr_price - b.f_jun_last_price * 100) / 100) /
DECODE(SIGN(a.f_curr_price - b.f_jun_last_price * 100),
1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) -
DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100),
-1, DECODE(a.f_curr_price, 0, b.f_jun_last_price / 100, a.f_curr_price / 100) +
DECODE(a.f_curr_price, 0, 0, (a.f_curr_price - b.f_jun_last_price * 100) / 100), 1), 2)) RATE,
a.f_net_vol NET_VOLUME,
a.f_out_vol OUTSTANDING_VOLUME,
a.f_net_turnover NET_TURNOVER
FROM t_sunmul_online a
JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code
WHERE a.f_stock_seq = :stockSequence
AND b.f_market_date = (SELECT MAX(open_day) FROM v_open_day)
AND b.f_month_gubun = :monthCategory
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedFuturesQuoteQuery = """
SELECT ' ' TITLE,
a.f_fo_price / 100 PRICE,
DECODE(SIGN(a.f_fo_price - a.f_base_price), 1, '+', -1, '-', 0, '') DIRECTION,
ABS(ROUND((a.f_fo_price - a.f_base_price) / 100, 2)) NET_CHANGE,
ROUND(((a.f_fo_price - a.f_base_price) / 100) /
DECODE(SIGN(a.f_fo_price - a.f_base_price),
1, (a.f_fo_price / 100) - ((a.f_fo_price - a.f_base_price) / 100),
-1, (a.f_fo_price / 100) + ((a.f_fo_price - a.f_base_price) / 100), 1), 2) RATE
FROM t_sunmul_online a
JOIN t_sunmul_batch b ON a.f_stock_code = b.f_stock_code
WHERE a.f_stock_seq = :stockSequence
AND b.f_month_gubun = :monthCategory
FETCH FIRST 2 ROWS ONLY
""";
private const string ForeignIndexQuery = """
SELECT a.f_input_name TITLE,
ROUND(b.f_last, 2) PRICE,
b.f_sign DIRECTION,
ROUND(b.f_diff, 2) NET_CHANGE,
ROUND(b.f_rate, 2) RATE
FROM t_world_ix_eq_master a
JOIN t_world_ix_eq_sise b ON a.f_symb = b.f_symb
WHERE a.f_symb = :symbol
FETCH FIRST 2 ROWS ONLY
""";
private const string ExchangeQuery = """
SELECT :displayName TITLE,
a.f_curr_price PRICE,
a.f_chg_type DIRECTION,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'+', a.f_curr_price - a.f_net_chg,
'-', a.f_curr_price + a.f_net_chg, 1)) * 100, 2) RATE
FROM t_input_index a
JOIN t_input_code b ON a.f_input_code = b.f_input_code
WHERE a.f_input_code = :inputCode
FETCH FIRST 2 ROWS ONLY
""";
private const string KospiIndustryQuery = """
SELECT b.f_part_name TITLE,
ROUND(a.f_part_idx / 100, 2) PRICE,
a.f_chg_type DIRECTION,
ROUND(a.f_part_chg / 100, 2) NET_CHANGE,
ROUND((a.f_part_chg / DECODE(a.f_chg_type,
'+', a.f_part_idx - a.f_part_chg,
'-', a.f_part_idx + a.f_part_chg, 1)) * 100, 2) RATE
FROM t_index a
JOIN t_part b ON a.f_part_code = b.f_part_code
WHERE b.f_part_name = :industryName
FETCH FIRST 2 ROWS ONLY
""";
private const string KosdaqIndustryQuery = """
SELECT b.f_part_name TITLE,
ROUND(a.f_part_idx / 100, 2) PRICE,
a.f_chg_type DIRECTION,
ROUND(a.f_part_chg / 100, 2) NET_CHANGE,
ROUND((a.f_part_chg / DECODE(a.f_chg_type,
'+', a.f_part_idx - a.f_part_chg,
'-', a.f_part_idx + a.f_part_chg, 1)) * 100, 2) RATE
FROM t_kosdaq_index a
JOIN t_kosdaq_part b ON a.f_part_code = b.f_part_code
WHERE b.f_part_name = :industryName
FETCH FIRST 2 ROWS ONLY
""";
private const string ForeignIndustryQuery = """
SELECT b.f_knam TITLE,
a.f_last PRICE,
a.f_sign DIRECTION,
ABS(ROUND(a.f_diff, 2)) NET_CHANGE,
ROUND(a.f_rate, 2) RATE
FROM t_world_ix_eq_sise a
JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb
WHERE b.f_knam = :industryName
FETCH FIRST 2 ROWS ONLY
""";
private const string ForeignStockQuery = """
SELECT b.f_input_name TITLE,
a.f_last PRICE,
a.f_sign DIRECTION,
ABS(ROUND(a.f_diff, 2)) NET_CHANGE,
ROUND(a.f_rate, 2) RATE
FROM t_world_ix_eq_sise a
JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb
WHERE b.f_input_name = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string KospiStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
ABS(a.f_net_chg) NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_online1 a
JOIN t_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string KosdaqStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_kosdaq_online1 a
JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string HaltedKospiStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
ABS(a.f_net_chg) NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_stop_online1 a
JOIN t_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'Y'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string HaltedKosdaqStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
ABS(a.f_net_chg) NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_stop_kosdaq_online1 a
JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'Y'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string NxtKospiStockQuery = """
SELECT DISTINCT b.f_stock_name TITLE,
a.f_curr_price PRICE,
CASE WHEN a.f_chg_type = '1' THEN ''
WHEN a.f_chg_type = '2' THEN ''
WHEN a.f_chg_type = '3' THEN ''
WHEN a.f_chg_type = '4' THEN ''
WHEN a.f_chg_type = '5' THEN '' END DIRECTION,
ABS(a.f_net_chg) NET_CHANGE,
ROUND((a.f_net_chg /
CASE WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) RATE
FROM n_online a
JOIN n_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_stop_gubun = 'N'
AND a.f_curr_price != 0
AND b.f_stock_name = @stockName
LIMIT 2
""";
private const string NxtKosdaqStockQuery = """
SELECT DISTINCT b.f_stock_name TITLE,
a.f_curr_price PRICE,
CASE WHEN a.f_chg_type = '1' THEN ''
WHEN a.f_chg_type = '2' THEN ''
WHEN a.f_chg_type = '3' THEN ''
WHEN a.f_chg_type = '4' THEN ''
WHEN a.f_chg_type = '5' THEN '' END DIRECTION,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg /
CASE WHEN a.f_chg_type IN ('1', '2') THEN a.f_curr_price - a.f_net_chg
WHEN a.f_chg_type = '3' THEN a.f_curr_price
WHEN a.f_chg_type IN ('4', '5') THEN -1 * (a.f_curr_price + a.f_net_chg) END) * 100, 2) RATE
FROM n_kosdaq_online a
JOIN n_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_stop_gubun = 'N'
AND a.f_curr_price != 0
AND b.f_stock_name = @stockName
LIMIT 2
""";
private const string ExpectedKospiStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_fore_price PRICE,
CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '+'
WHEN a.f_fore_price - c.f_final_price < 0 THEN '-'
ELSE '' END DIRECTION,
ABS(a.f_fore_price - c.f_final_price) NET_CHANGE,
ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE
FROM t_online1_call a
JOIN t_stock b ON b.f_stock_code = a.f_stock_code
JOIN t_batch_day c ON c.f_stock_code = a.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND a.f_fore_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string ExpectedKosdaqStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_fore_price PRICE,
CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '+'
WHEN a.f_fore_price - c.f_final_price < 0 THEN '-'
ELSE '' END DIRECTION,
ABS(a.f_fore_price - c.f_final_price) NET_CHANGE,
ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE
FROM t_kosdaq_online1_call a
JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
JOIN t_kosdaq_batch_day c ON c.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND a.f_fore_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string AfterHoursKospiStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_overtime_sise a
JOIN t_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND b.f_sect_code = 'ST'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string AfterHoursKosdaqStockQuery = """
SELECT DISTINCT b.f_stock_wanname TITLE,
a.f_curr_price PRICE,
DECODE(a.f_chg_type, '1', '', '2', '', '3', '', '4', '', '5', '') DIRECTION,
a.f_net_chg NET_CHANGE,
ROUND((a.f_net_chg / DECODE(a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM t_kosdaq_overtime_sise a
JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND b.f_sect_code = 'ST'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
FETCH FIRST 2 ROWS ONLY
""";
private const string KospiSectorsQuery = """
SELECT a.f_part_code SECTOR_CODE,
b.f_part_name SECTOR_NAME,
a.f_chg_type DIRECTION,
ABS(ROUND(a.f_part_chg /
DECODE(a.f_chg_type,
'+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
'-', (a.f_part_idx / 100) + (a.f_part_chg / 100), 1), 2)) MAGNITUDE
FROM t_index a
JOIN t_part b ON a.f_part_code = b.f_part_code
WHERE a.f_part_code IN (
'013', '009', '011', '016', '018',
'021', '008', '020', '007', '012',
'015', '005', '014', '006', '019')
ORDER BY a.f_part_code
FETCH FIRST 15 ROWS ONLY
""";
private const string KosdaqSectorsQuery = """
SELECT a.f_part_code SECTOR_CODE,
b.f_part_name SECTOR_NAME,
a.f_chg_type DIRECTION,
ABS(ROUND(a.f_part_chg /
DECODE(a.f_chg_type,
'+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
'-', (a.f_part_idx / 100) + (a.f_part_chg / 100), 1), 2)) MAGNITUDE
FROM t_kosdaq_index a
JOIN t_kosdaq_part b ON a.f_part_code = b.f_part_code
WHERE b.f_part_name IN (
:sector01, :sector02, :sector03,
:sector04, :sector05, :sector06)
ORDER BY a.f_part_code
FETCH FIRST 7 ROWS ONLY
""";
}

View File

@@ -0,0 +1,697 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum LegacyDomesticEquityMarket
{
Kospi,
Kosdaq,
NxtKospi,
NxtKosdaq
}
public sealed record S5006SceneData(
LegacyDomesticEquityMarket Market,
string StockName,
ScenePriceDirection Direction,
long CurrentPrice,
long NetChange,
decimal Rate,
long OpenPrice,
decimal OpenRate,
long HighPrice,
decimal HighRate,
long LowPrice,
decimal LowRate) : ILegacySceneData;
public sealed class S5006SceneMutationBuilder : ILegacySceneMutationBuilder<S5006SceneData>
{
private const string BackgroundAsset = @"Video\큐브배경.vrv";
public string BuilderKey => "s5006";
public IReadOnlyList<PlayoutMutation> Build(S5006SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
ClosedSceneMappings.RequireDomesticMarket(data.Market);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var direction = ClosedSceneMappings.RequireDirection(data.Direction);
var variant = ClosedSceneMappings.DirectionValueVariant(direction);
var mutations = new List<PlayoutMutation>(38)
{
new PlayoutSetValue("title", stockName),
new PlayoutSetBackgroundVideo(
BackgroundAsset,
1,
true,
PlayoutMutationTiming.InTransaction),
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange)),
new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)),
new PlayoutSetValue("price1", FormatInteger(data.OpenPrice)),
new PlayoutSetValue("ratio1", FormatRate(data.OpenRate)),
new PlayoutSetValue("price2", FormatInteger(data.HighPrice)),
new PlayoutSetValue("ratio2", FormatRate(data.HighRate)),
new PlayoutSetValue("price3", FormatInteger(data.LowPrice)),
new PlayoutSetValue("ratio3", FormatRate(data.LowRate))
};
ClosedSceneMappings.AddDirectionHides(mutations, string.Empty);
mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0));
return mutations;
}
private static string FormatInteger(long value) =>
value.ToString("#,##0", CultureInfo.InvariantCulture);
private static string FormatRate(decimal value) =>
value.ToString("##0.00", CultureInfo.InvariantCulture);
}
public enum S5011DetailBranch
{
FaceValue,
Valuation,
Volume
}
public abstract record S5011DetailData
{
private protected S5011DetailData()
{
}
public abstract S5011DetailBranch Branch { get; }
}
public sealed record S5011FaceValueDetail(
long ListedPrice,
long CapitalPrice,
long MarketCapitalization,
long MarketRank) : S5011DetailData
{
public override S5011DetailBranch Branch => S5011DetailBranch.FaceValue;
}
public sealed record S5011ValuationDetail(
decimal Pbr,
decimal Per,
long Bps,
long Eps) : S5011DetailData
{
public override S5011DetailBranch Branch => S5011DetailBranch.Valuation;
}
public sealed record S5011VolumeDetail(
long Volume,
long Turnover,
long FiveDayAverage,
long TwentyDayAverage) : S5011DetailData
{
public override S5011DetailBranch Branch => S5011DetailBranch.Volume;
}
public sealed record S5011SceneData(
LegacyDomesticEquityMarket Market,
string StockName,
ScenePriceDirection Direction,
long CurrentPrice,
long NetChange,
decimal Rate,
S5011DetailData Detail) : ILegacySceneData;
public sealed class S5011SceneMutationBuilder : ILegacySceneMutationBuilder<S5011SceneData>
{
public string BuilderKey => "s5011";
public IReadOnlyList<PlayoutMutation> Build(S5011SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var market = ClosedSceneMappings.RequireDomesticMarket(data.Market);
var rawName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
var stockName = LegacySceneBuilderGuard.Text(
rawName.Replace("(NXT)", string.Empty, StringComparison.Ordinal),
nameof(data.StockName));
var direction = ClosedSceneMappings.RequireDirection(data.Direction);
var detail = LegacySceneBuilderGuard.NotNull(data.Detail, nameof(data.Detail));
var variant = ClosedSceneMappings.DirectionValueVariant(direction);
var mutations = new List<PlayoutMutation>(44)
{
new PlayoutSetValue("title", stockName)
};
AddDetailMutations(mutations, detail);
mutations.Add(new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)));
mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(data.NetChange)));
mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)));
ClosedSceneMappings.AddDirectionHides(mutations, string.Empty);
mutations.AddRange(LegacyPriceDirectionMutations.Build(direction, 0));
mutations.Add(new PlayoutSetVisible("mart", ClosedSceneMappings.IsNxt(market)));
return mutations;
}
private static void AddDetailMutations(
ICollection<PlayoutMutation> mutations,
S5011DetailData detail)
{
switch (detail)
{
case S5011FaceValueDetail faceValue:
AddLabels(
mutations,
["원", "억원", "억원", "위"],
["액면가", "자본금", "시가총액", "시총순위"]);
AddValues(
mutations,
[
FormatInteger(faceValue.ListedPrice),
FormatInteger(faceValue.CapitalPrice / 100_000_000L),
FormatInteger(faceValue.MarketCapitalization / 100_000_000L),
FormatInteger(faceValue.MarketRank)
]);
break;
case S5011ValuationDetail valuation:
AddLabels(
mutations,
["배", "배", "원", "원"],
["PBR", "PER", "BPS", "EPS"]);
AddValues(
mutations,
[
valuation.Pbr.ToString(CultureInfo.InvariantCulture),
valuation.Per.ToString(CultureInfo.InvariantCulture),
FormatInteger(valuation.Bps),
FormatInteger(valuation.Eps)
]);
break;
case S5011VolumeDetail volume:
AddLabels(
mutations,
["주", "백만", "원", "원"],
["거래량", "거래대금", "5MA", "20MA"]);
AddValues(
mutations,
[
FormatInteger(volume.Volume),
FormatInteger(volume.Turnover / 1_000_000L),
volume.FiveDayAverage.ToString("#,##0.###", CultureInfo.InvariantCulture),
volume.TwentyDayAverage.ToString("#,##0.###", CultureInfo.InvariantCulture)
]);
break;
default:
throw new LegacySceneDataException("Unknown s5011 detail branch.");
}
}
private static void AddLabels(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<string> units,
IReadOnlyList<string> titles)
{
for (var index = 1; index <= 4; index++)
{
mutations.Add(new PlayoutSetValue("r_unit" + index, units[index - 1]));
}
for (var index = 1; index <= 4; index++)
{
mutations.Add(new PlayoutSetValue("r_title" + index, titles[index - 1]));
}
}
private static void AddValues(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<string> values)
{
for (var index = 1; index <= 4; index++)
{
mutations.Add(new PlayoutSetValue("r_value" + index, values[index - 1]));
}
}
private static string FormatInteger(long value) =>
value.ToString("#,##0", CultureInfo.InvariantCulture);
private static string FormatRate(decimal value) =>
value.ToString("##0.00", CultureInfo.InvariantCulture);
}
public enum LegacyPanelQuoteSignal
{
Positive,
Flat,
Negative
}
public sealed record LegacyPanelQuoteData(
string Name,
string? PartName,
decimal Price,
decimal Change,
decimal Rate,
LegacyPanelQuoteSignal Signal);
public enum S5016PanelTarget
{
UnitedStatesIndices,
GreaterChinaIndices,
EuropeanIndices,
AsianIndices,
UnitedStatesTreasuries,
BondYields,
Oil,
Minerals,
FoodMaterials,
OverseasExchangeRates,
DomesticExchangeRates,
AgricultureWheatCorn,
AgricultureSoyRice,
AgricultureCoffeeCocoaSugar,
AgricultureLivestock,
RawMaterialsCopperIronNickel,
RawMaterialsGasPlatinumPalladium,
RawMaterialsLeadZincTin,
MajorKospiKosdaqKospi200,
MajorKospiKosdaqFutures,
ExpectedKospiKosdaqKospi200,
ExpectedKospiKosdaqFutures
}
public sealed record S5016SceneData(
S5016PanelTarget Target,
IReadOnlyList<LegacyPanelQuoteData> Quotes) : ILegacySceneData;
public sealed class S5016SceneMutationBuilder : ILegacySceneMutationBuilder<S5016SceneData>
{
public string BuilderKey => "s5016";
public IReadOnlyList<PlayoutMutation> Build(S5016SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var metadata = Metadata(data.Target);
var quotes = LegacySceneBuilderGuard.Count(data.Quotes, 3, nameof(data.Quotes));
var mutations = new List<PlayoutMutation>(79)
{
new PlayoutSetValue("title", metadata.Title)
};
for (var index = 1; index <= 3; index++)
{
var quote = LegacySceneBuilderGuard.NotNull(quotes[index - 1], $"Quotes[{index - 1}]");
var name = metadata.UsePartName
? LegacySceneBuilderGuard.Text(quote.PartName, $"Quotes[{index - 1}].PartName")
: LegacySceneBuilderGuard.Text(quote.Name, $"Quotes[{index - 1}].Name")
.Replace("KOSPI", "코스피", StringComparison.Ordinal)
.Replace("KOSQ", "코스닥", StringComparison.Ordinal);
LegacyPanelQuoteMutations.AddQuote(
mutations,
index,
name,
quote,
metadata.ChangeDecimalPlaces);
mutations.Add(new PlayoutSetValue("unitText", metadata.UnitText));
}
return mutations;
}
private static S5016Metadata Metadata(S5016PanelTarget target) => target switch
{
S5016PanelTarget.UnitedStatesIndices => new("미국 지수", "단위 : p"),
S5016PanelTarget.GreaterChinaIndices => new("중화권 지수", "단위 : p"),
S5016PanelTarget.EuropeanIndices => new("유럽 지수", "단위 : p"),
S5016PanelTarget.AsianIndices => new("아시아 지수", "단위 : p"),
S5016PanelTarget.UnitedStatesTreasuries => new("미국 국채", "단위 : %"),
S5016PanelTarget.BondYields => new("채권 금리", "단위 : %", UsePartName: true),
S5016PanelTarget.Oil => new("유가", "단위 : 배럴/달러"),
S5016PanelTarget.Minerals => new("광물", "단위 : $"),
S5016PanelTarget.FoodMaterials => new("식자재", "단위 : ¢"),
S5016PanelTarget.OverseasExchangeRates => new(
"해외 환율",
"단위 : $",
ChangeDecimalPlaces: 4),
S5016PanelTarget.DomesticExchangeRates => new("환율", "단위 : 원"),
S5016PanelTarget.AgricultureWheatCorn or
S5016PanelTarget.AgricultureSoyRice or
S5016PanelTarget.AgricultureCoffeeCocoaSugar or
S5016PanelTarget.AgricultureLivestock => new("농산물", "단위 : $"),
S5016PanelTarget.RawMaterialsCopperIronNickel or
S5016PanelTarget.RawMaterialsGasPlatinumPalladium or
S5016PanelTarget.RawMaterialsLeadZincTin => new("원자재", "단위 : $"),
S5016PanelTarget.MajorKospiKosdaqKospi200 or
S5016PanelTarget.MajorKospiKosdaqFutures => new("주요지수", "단위 : p"),
S5016PanelTarget.ExpectedKospiKosdaqKospi200 or
S5016PanelTarget.ExpectedKospiKosdaqFutures => new("예상체결지수", "단위 : p"),
_ => throw new LegacySceneDataException("Unknown s5016 panel target.")
};
private readonly record struct S5016Metadata(
string Title,
string UnitText,
bool UsePartName = false,
int ChangeDecimalPlaces = 2);
}
public enum S50160PanelTarget
{
Cotton,
InternationalPreciousMetals,
DomesticPreciousMetals
}
public sealed record S50160SceneData(
S50160PanelTarget Target,
IReadOnlyList<LegacyPanelQuoteData> Quotes) : ILegacySceneData;
public sealed class S50160SceneMutationBuilder : ILegacySceneMutationBuilder<S50160SceneData>
{
public string BuilderKey => "s50160";
public IReadOnlyList<PlayoutMutation> Build(S50160SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var metadata = Metadata(data.Target);
var quotes = LegacySceneBuilderGuard.Count(data.Quotes, 2, nameof(data.Quotes));
var mutations = new List<PlayoutMutation>(59)
{
new PlayoutSetValue("title", metadata.Title)
};
for (var index = 1; index <= 2; index++)
{
var quote = LegacySceneBuilderGuard.NotNull(quotes[index - 1], $"Quotes[{index - 1}]");
var name = LegacySceneBuilderGuard.Text(quote.Name, $"Quotes[{index - 1}].Name")
.Replace("KOSPI", "코스피", StringComparison.Ordinal)
.Replace("KOSQ", "코스닥", StringComparison.Ordinal);
LegacyPanelQuoteMutations.AddQuote(mutations, index, name, quote, 2);
mutations.Add(new PlayoutSetValue("unit" + index + "_1", string.Empty));
mutations.Add(new PlayoutSetValue("unit" + index + "_2", string.Empty));
mutations.Add(new PlayoutSetValue("unit" + index + "_3", string.Empty));
mutations.Add(new PlayoutSetValue("unitText", metadata.UnitText));
}
return mutations;
}
private static S50160Metadata Metadata(S50160PanelTarget target) => target switch
{
S50160PanelTarget.Cotton => new("농산물", "단위 : $"),
S50160PanelTarget.InternationalPreciousMetals => new("원자재", "단위 : $"),
S50160PanelTarget.DomesticPreciousMetals => new("원자재", "단위 : 원"),
_ => throw new LegacySceneDataException("Unknown s50160 panel target.")
};
private readonly record struct S50160Metadata(string Title, string UnitText);
}
public enum S6001QuoteTarget
{
DowJones,
Nasdaq,
StandardAndPoor500,
Germany,
UnitedKingdom,
France,
Japan,
China,
HongKong,
Taiwan,
Singapore,
Thailand,
Philippines,
Malaysia,
Indonesia,
DubaiCrude,
BrentCrude,
WtiCrude,
Gold
}
public sealed record S6001SceneData(
S6001QuoteTarget Target,
string Title,
decimal Price,
decimal Change,
decimal Rate,
ScenePriceDirection Direction) : ILegacySceneData;
public sealed class S6001SceneMutationBuilder : ILegacySceneMutationBuilder<S6001SceneData>
{
public string BuilderKey => "s6001";
public IReadOnlyList<PlayoutMutation> Build(S6001SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var metadata = Metadata(data.Target);
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
var direction = ClosedSceneMappings.RequireDirection(data.Direction);
if (data.Change == decimal.MinValue || data.Rate == decimal.MinValue)
{
throw new LegacySceneDataException("s6001 quote values are outside the supported range.");
}
var mutations = new List<PlayoutMutation>(19);
if (metadata.IsIndex)
{
mutations.Add(new PlayoutSetValue("title", title));
mutations.Add(new PlayoutSetAssetValue("movie", metadata.AssetPath));
mutations.Add(new PlayoutSetValue("price", FormatDecimal(data.Price)));
mutations.Add(new PlayoutSetValue("changePrice", FormatDecimal(Math.Abs(data.Change))));
var signedRate = direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown
? -Math.Abs(data.Rate)
: data.Rate;
mutations.Add(new PlayoutSetValue("rate", FormatDecimal(signedRate)));
mutations.Add(new PlayoutSetValue("wonText", string.Empty));
mutations.Add(new PlayoutSetValue("unit", metadata.UnitText));
}
else
{
mutations.Add(new PlayoutSetAssetValue("movie", metadata.AssetPath));
mutations.Add(new PlayoutSetValue("unit", metadata.UnitText));
mutations.Add(new PlayoutSetValue("title", title));
mutations.Add(new PlayoutSetValue("price", FormatDecimal(data.Price)));
mutations.Add(new PlayoutSetValue("changePrice", FormatDecimal(data.Change)));
mutations.Add(new PlayoutSetValue("rate", FormatDecimal(data.Rate)));
mutations.Add(new PlayoutSetValue("wonText", string.Empty));
}
S6001DirectionMutations.Add(mutations, direction);
return mutations;
}
private static S6001Metadata Metadata(S6001QuoteTarget target) => target switch
{
S6001QuoteTarget.DowJones or
S6001QuoteTarget.Nasdaq or
S6001QuoteTarget.StandardAndPoor500 => Index("미국"),
S6001QuoteTarget.Germany => Index("독일"),
S6001QuoteTarget.UnitedKingdom => Index("영국"),
S6001QuoteTarget.France => Index("프랑스"),
S6001QuoteTarget.Japan => Index("일본"),
S6001QuoteTarget.China => Index("중국"),
S6001QuoteTarget.HongKong => Index("홍콩"),
S6001QuoteTarget.Taiwan => Index("대만"),
S6001QuoteTarget.Singapore => Index("싱가포르"),
S6001QuoteTarget.Thailand => Index("태국"),
S6001QuoteTarget.Philippines => Index("필리핀"),
S6001QuoteTarget.Malaysia => Index("말레이시아"),
S6001QuoteTarget.Indonesia => Index("인도네시아"),
S6001QuoteTarget.DubaiCrude or
S6001QuoteTarget.BrentCrude or
S6001QuoteTarget.WtiCrude => new(@"images\주유기merge.png", "단위 : 배럴/달러", false),
S6001QuoteTarget.Gold => new(@"images\35752913_l.jpg", "단위 : 달러/온스", false),
_ => throw new LegacySceneDataException("Unknown s6001 quote target.")
};
private static S6001Metadata Index(string nation) =>
new(@"Video\20201008_" + nation + ".vrv", "단위 : p", true);
private static string FormatDecimal(decimal value) =>
value.ToString("#,##0.00", CultureInfo.InvariantCulture);
private readonly record struct S6001Metadata(
string AssetPath,
string UnitText,
bool IsIndex);
}
internal static class LegacyPanelQuoteMutations
{
private static readonly string[] VariantFamilies =
["bg", "rate", "changePrice", "percent", "unit"];
public static void AddQuote(
ICollection<PlayoutMutation> mutations,
int index,
string name,
LegacyPanelQuoteData quote,
int changeDecimalPlaces)
{
if (quote.Change == decimal.MinValue)
{
throw new LegacySceneDataException("Panel quote change is outside the supported range.");
}
var variant = quote.Signal switch
{
LegacyPanelQuoteSignal.Positive => 1,
LegacyPanelQuoteSignal.Flat => 2,
LegacyPanelQuoteSignal.Negative when quote.Change == 0 => 2,
LegacyPanelQuoteSignal.Negative => 3,
_ => throw new LegacySceneDataException("Unknown panel quote signal.")
};
ClosedSceneMappings.AddPanelDirectionHides(
mutations,
index.ToString(CultureInfo.InvariantCulture));
mutations.Add(new PlayoutSetValue("title" + index, name));
mutations.Add(new PlayoutSetValue(
"price" + index,
quote.Price.ToString("#,##0.00", CultureInfo.InvariantCulture)));
foreach (var family in VariantFamilies)
{
for (var visual = 1; visual <= 3; visual++)
{
mutations.Add(new PlayoutSetVisible(
family + index + "_" + visual,
visual == variant));
}
}
var directionObject = variant switch
{
1 => "up",
2 => "flat",
3 => "down",
_ => throw new InvalidOperationException("A known panel quote variant was expected.")
};
mutations.Add(new PlayoutSetVisible(directionObject + index, true));
var changeFormat = changeDecimalPlaces switch
{
2 => "#,##0.00",
4 => "#,##0.0000",
_ => throw new LegacySceneDataException("Unsupported panel quote precision.")
};
mutations.Add(new PlayoutSetValue(
"changePrice" + index + "_" + variant,
Math.Abs(quote.Change).ToString(changeFormat, CultureInfo.InvariantCulture)));
var ratePrefix = variant == 3 && quote.Rate >= 0 ? "-" : string.Empty;
mutations.Add(new PlayoutSetValue(
"rate" + index + "_" + variant,
ratePrefix + quote.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
}
}
internal static class S6001DirectionMutations
{
private const string RedPattern = @"Images\그림_빨강.png";
private const string FlatPattern = @"Images\그림_검정.png";
private const string BluePattern = @"Images\그림_파랑.png";
private static readonly string[] ColorObjects =
["bg", "changePrice", "wonText", "rate", "percent"];
public static void Add(
ICollection<PlayoutMutation> mutations,
ScenePriceDirection direction)
{
ClosedSceneMappings.AddDirectionHides(mutations, string.Empty);
var (directionObject, background, foreground, pattern) = direction switch
{
ScenePriceDirection.LimitUp => ("upup", (170, 0, 0), (170, 0, 0), RedPattern),
ScenePriceDirection.Up => ("up", (170, 0, 0), (170, 0, 0), RedPattern),
ScenePriceDirection.Flat => ("flat", (95, 95, 95), (52, 52, 52), FlatPattern),
ScenePriceDirection.Down => ("down", (15, 99, 189), (15, 99, 189), BluePattern),
ScenePriceDirection.LimitDown => ("downdown", (15, 99, 189), (15, 99, 189), BluePattern),
_ => throw new LegacySceneDataException("Unknown s6001 price direction.")
};
mutations.Add(new PlayoutSetVisible(directionObject, true));
for (var index = 0; index < ColorObjects.Length; index++)
{
var color = index == 0 ? background : foreground;
mutations.Add(new PlayoutSetFaceColor(
ColorObjects[index],
color.Item1,
color.Item2,
color.Item3));
}
mutations.Add(new PlayoutSetAssetValue("pattern", pattern));
}
}
internal static class ClosedSceneMappings
{
private static readonly string[] DirectionObjects =
["upup", "up", "flat", "down", "downdown"];
private static readonly string[] PanelDirectionObjects =
["flat", "up", "upup", "downdown", "down"];
public static LegacyDomesticEquityMarket RequireDomesticMarket(
LegacyDomesticEquityMarket market) => market switch
{
LegacyDomesticEquityMarket.Kospi => market,
LegacyDomesticEquityMarket.Kosdaq => market,
LegacyDomesticEquityMarket.NxtKospi => market,
LegacyDomesticEquityMarket.NxtKosdaq => market,
_ => throw new LegacySceneDataException("Unknown domestic equity market.")
};
public static bool IsNxt(LegacyDomesticEquityMarket market) => market switch
{
LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.Kosdaq => false,
LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq => true,
_ => throw new LegacySceneDataException("Unknown domestic equity market.")
};
public static ScenePriceDirection RequireDirection(ScenePriceDirection direction) =>
direction switch
{
ScenePriceDirection.LimitUp => direction,
ScenePriceDirection.Up => direction,
ScenePriceDirection.Flat => direction,
ScenePriceDirection.Down => direction,
ScenePriceDirection.LimitDown => direction,
_ => throw new LegacySceneDataException("Unknown price direction.")
};
public static string DirectionValueVariant(ScenePriceDirection direction) => direction switch
{
ScenePriceDirection.LimitUp or ScenePriceDirection.Up => "1_",
ScenePriceDirection.Flat => "2_",
ScenePriceDirection.Down or ScenePriceDirection.LimitDown => "3_",
_ => throw new LegacySceneDataException("Unknown price direction.")
};
public static void AddDirectionHides(
ICollection<PlayoutMutation> mutations,
string suffix)
{
foreach (var objectName in DirectionObjects)
{
mutations.Add(new PlayoutSetVisible(objectName + suffix, false));
}
}
public static void AddPanelDirectionHides(
ICollection<PlayoutMutation> mutations,
string suffix)
{
foreach (var objectName in PanelDirectionObjects)
{
mutations.Add(new PlayoutSetVisible(objectName + suffix, false));
}
}
}

View File

@@ -0,0 +1,206 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum LegacySceneBackgroundKind
{
None,
Texture,
Video
}
/// <summary>
/// Trusted, native configuration for the MainForm-level scene background and fade.
/// It is constructed by the app configuration layer and is never deserialized from Web input.
/// </summary>
public sealed record LegacySceneCueCompositionOptions(
int FadeDuration,
LegacySceneBackgroundKind BackgroundKind,
string? BackgroundAssetPath = null,
int BackgroundVideoLoopCount = 2004,
bool BackgroundVideoLoopInfinite = true)
{
public static LegacySceneCueCompositionOptions Default { get; } = new(
// MainForm initializes ComboDi.SelectedIndex to 6 and passes that value
// to every SetSceneEffectType call.
FadeDuration: 6,
BackgroundKind: LegacySceneBackgroundKind.None);
}
/// <summary>
/// One trusted DTO produced by a scene-specific database loader. BuilderKey resolves
/// conditional aliases such as 5032/8018 without accepting K3D object or method names.
/// </summary>
public sealed record LegacySceneDataPage(
string BuilderKey,
ILegacySceneData Data,
int ItemCount);
public interface ILegacySceneDataSource
{
Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Converts a native DB DTO into a complete cue while preserving the legacy order:
/// load/effect/background, BeginTransaction, ordered builder mutations,
/// QueryVariables, EndTransaction, Prepare. The latter operations are performed by
/// IPlayoutEngine; this provider exposes no COM or reflection surface.
/// </summary>
public sealed class RegisteredLegacySceneCueProvider : ILegacySceneCueProvider
{
private readonly ILegacySceneDataSource _dataSource;
private readonly LegacySceneMutationBuilderRegistry _builders;
private readonly LegacySceneCueCompositionOptions _options;
public RegisteredLegacySceneCueProvider(
ILegacySceneDataSource dataSource,
LegacySceneMutationBuilderRegistry? builders = null,
LegacySceneCueCompositionOptions? options = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_builders = builders ?? LegacySceneMutationBuilderRegistry.Default;
_options = ValidateOptions(options ?? LegacySceneCueCompositionOptions.Default);
}
public async Task<LegacySceneCuePage> CreatePageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (pageIndexZeroBased < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased));
}
var loaded = await _dataSource.LoadPageAsync(
entry,
pageIndexZeroBased,
cancellationToken).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(loaded);
ArgumentNullException.ThrowIfNull(loaded.Data);
if (loaded.ItemCount < 0)
{
throw new LegacySceneDataException("A scene loader returned an invalid item count.");
}
var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate =>
string.Equals(candidate.BuilderKey, loaded.BuilderKey, StringComparison.Ordinal));
if (definition is null ||
definition.Reachability == LegacySceneReachability.NoMainFormDispatch ||
!definition.CutAliases.Contains(entry.CutCode, StringComparer.Ordinal))
{
throw new LegacySceneDataException(
"The scene loader returned a builder that is not valid for the selected cut.");
}
var pageSize = ConvertPageSize(definition.PageSize);
if (pageSize is null && pageIndexZeroBased != 0)
{
throw new LegacySceneDataException("A non-paged scene cannot load another page.");
}
if (pageSize is { } size)
{
var window = ScenePaging.GetWindow(
loaded.ItemCount,
size,
pageIndexZeroBased + 1);
if (!window.IsValid)
{
throw new LegacySceneDataException(
"The requested scene page is outside the accessible 20-page range.");
}
}
var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data);
var mutations = new List<PlayoutMutation>(builderMutations.Count + 2);
AddBackgroundMutations(mutations, _options);
mutations.AddRange(builderMutations);
return new LegacySceneCuePage(
new PlayoutCue(
entry.CutCode + ".t2s",
entry.CutCode,
FadeDuration: entry.FadeDuration ?? _options.FadeDuration,
Mutations: Array.AsReadOnly(mutations.ToArray())),
loaded.ItemCount,
pageSize,
definition.BuilderKey,
LegacyScenePreviewFactory.Create(Array.AsReadOnly(mutations.ToArray())));
}
private static ScenePageSize? ConvertPageSize(LegacyScenePageSize value) => value switch
{
LegacyScenePageSize.None => null,
LegacyScenePageSize.Five => ScenePageSize.Five,
LegacyScenePageSize.Six => ScenePageSize.Six,
LegacyScenePageSize.Twelve => ScenePageSize.Twelve,
_ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.")
};
private static LegacySceneCueCompositionOptions ValidateOptions(
LegacySceneCueCompositionOptions options)
{
if (options.FadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.BackgroundKind) ||
(options.BackgroundKind == LegacySceneBackgroundKind.Video &&
options.BackgroundVideoLoopCount is < 0 or > 10_000))
{
throw new ArgumentException("The legacy cue composition options are invalid.", nameof(options));
}
if (options.BackgroundKind == LegacySceneBackgroundKind.None)
{
if (!string.IsNullOrEmpty(options.BackgroundAssetPath))
{
throw new ArgumentException(
"A disabled background cannot specify an asset.",
nameof(options));
}
return options;
}
var path = options.BackgroundAssetPath;
if (string.IsNullOrWhiteSpace(path) || path.Length > 512 ||
Path.IsPathRooted(path) || path.Contains("..", StringComparison.Ordinal) ||
path.Any(char.IsControl))
{
throw new ArgumentException(
"The background asset must be a safe relative path.",
nameof(options));
}
return options with { BackgroundAssetPath = path.Trim() };
}
private static void AddBackgroundMutations(
ICollection<PlayoutMutation> mutations,
LegacySceneCueCompositionOptions options)
{
switch (options.BackgroundKind)
{
case LegacySceneBackgroundKind.None:
mutations.Add(new PlayoutUseBackground(false));
break;
case LegacySceneBackgroundKind.Texture:
mutations.Add(new PlayoutSetBackgroundTexture(options.BackgroundAssetPath!));
mutations.Add(new PlayoutUseBackground(true));
break;
case LegacySceneBackgroundKind.Video:
mutations.Add(new PlayoutSetBackgroundVideo(
options.BackgroundAssetPath!,
options.BackgroundVideoLoopCount,
options.BackgroundVideoLoopInfinite));
mutations.Add(new PlayoutUseBackground(true));
break;
default:
throw new InvalidOperationException("Unknown background kind.");
}
}
}

View File

@@ -0,0 +1,602 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum LegacyDomesticMarket
{
Kospi,
Kosdaq
}
public sealed record InvestorFlowAmounts(
int Individual,
int Foreign,
int Institution);
public sealed record DailyInvestorFlowData(
DateOnly TradingDate,
InvestorFlowAmounts Amounts);
public sealed record S5023SceneData(
LegacyDomesticMarket Market,
IReadOnlyList<DailyInvestorFlowData?> DailyRows,
InvestorFlowAmounts MonthlyTotal) : ILegacySceneData;
/// <summary>
/// Legacy index-investor table. Five nullable rows are required so empty objects are
/// deterministically cleared instead of retaining data from an earlier cue.
/// </summary>
public sealed class S5023SceneMutationBuilder : ILegacySceneMutationBuilder<S5023SceneData>
{
private const int RowCount = 5;
public string BuilderKey => "s5023";
public IReadOnlyList<PlayoutMutation> Build(S5023SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var rows = LegacySceneBuilderGuard.Count(
data.DailyRows,
RowCount,
nameof(data.DailyRows));
var title = MarketTitle(data.Market) + " \uB9E4\uB9E4\uB3D9\uD5A5";
var monthlyTotal = LegacySceneBuilderGuard.NotNull(
data.MonthlyTotal,
nameof(data.MonthlyTotal));
var normalizedRows = rows.Select((row, index) => row is null
? null
: new DailyInvestorFlowData(
row.TradingDate,
LegacySceneBuilderGuard.NotNull(
row.Amounts,
$"DailyRows[{index}].Amounts")))
.ToArray();
var mutations = new List<PlayoutMutation>(43)
{
new PlayoutSetValue("title", title)
};
for (var rowIndex = 0; rowIndex < RowCount; rowIndex++)
{
var rowNumber = rowIndex + 1;
var row = normalizedRows[rowIndex];
mutations.Add(new PlayoutSetValue(
$"row{rowNumber}_value1",
row?.TradingDate.ToString("MM/dd", CultureInfo.InvariantCulture) ?? string.Empty));
AddDailyAmount(mutations, rowNumber, 2, row?.Amounts.Individual);
AddDailyAmount(mutations, rowNumber, 3, row?.Amounts.Foreign);
AddDailyAmount(mutations, rowNumber, 4, row?.Amounts.Institution);
}
AddMonthlyTotal(mutations, 1, monthlyTotal.Individual);
AddMonthlyTotal(mutations, 2, monthlyTotal.Foreign);
AddMonthlyTotal(mutations, 3, monthlyTotal.Institution);
return mutations;
}
private static void AddDailyAmount(
ICollection<PlayoutMutation> mutations,
int row,
int column,
int? value)
{
var objectName = $"row{row}_value{column}";
var color = value switch
{
> 0 => (R: 171, G: 0, B: 0),
< 0 => (R: 15, G: 99, B: 188),
_ => (R: 47, G: 47, B: 47)
};
mutations.Add(new PlayoutSetFaceColor(objectName, color.R, color.G, color.B));
mutations.Add(new PlayoutSetValue(
objectName,
value?.ToString("#,##0", CultureInfo.InvariantCulture) ?? string.Empty));
}
private static void AddMonthlyTotal(
ICollection<PlayoutMutation> mutations,
int column,
int value)
{
mutations.Add(new PlayoutSetValue(
"row6_value" + column,
value.ToString("#,##0", CultureInfo.InvariantCulture)));
var color = value switch
{
> 0 => (R: 171, G: 0, B: 0),
< 0 => (R: 15, G: 99, B: 189),
_ => (R: 95, G: 95, B: 95)
};
mutations.Add(new PlayoutSetFaceColor(
"row6_bg" + column,
color.R,
color.G,
color.B));
}
internal static string MarketTitle(LegacyDomesticMarket market) => market switch
{
LegacyDomesticMarket.Kospi => "\uCF54\uC2A4\uD53C",
LegacyDomesticMarket.Kosdaq => "\uCF54\uC2A4\uB2E5",
_ => throw new LegacySceneDataException("Unknown domestic market.")
};
}
public enum InvestorParticipant
{
Individual,
Foreign,
Institution
}
public sealed record InvestorTradingAmountData(
InvestorParticipant Participant,
double Amount);
public sealed record S5024SceneData(
LegacyDomesticMarket Market,
IReadOnlyList<InvestorTradingAmountData> Amounts) : ILegacySceneData;
public sealed class S5024SceneMutationBuilder : ILegacySceneMutationBuilder<S5024SceneData>
{
private const double CenterMaximumY = 245d;
private const double CenterMinimumY = -150d;
private const double TextHeight = 50d;
private const double Height = CenterMaximumY - CenterMinimumY - (TextHeight * 2d);
public string BuilderKey => "s5024";
public IReadOnlyList<PlayoutMutation> Build(S5024SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var amounts = LegacySceneBuilderGuard.Range(
data.Amounts,
0,
3,
nameof(data.Amounts));
if (amounts.Any(item => item is null || !double.IsFinite(item.Amount)))
{
throw new LegacySceneDataException("Investor trading amounts must be finite.");
}
if (amounts.Select(item => item.Participant).Distinct().Count() != amounts.Count)
{
throw new LegacySceneDataException("Investor participants must be unique.");
}
// Validate every enum before emitting any mutation.
foreach (var item in amounts)
{
_ = ParticipantIndex(item.Participant);
}
var maximum = amounts.Count == 0 ? 0d : Math.Max(0d, amounts.Max(item => item.Amount));
var minimum = amounts.Count == 0 ? 0d : Math.Min(0d, amounts.Min(item => item.Amount));
var mutations = new List<PlayoutMutation>(17)
{
new PlayoutSetValue(
"title",
S5023SceneMutationBuilder.MarketTitle(data.Market) + " \uB9E4\uB9E4\uB3D9\uD5A5")
};
for (var participant = 1; participant <= 3; participant++)
{
for (var signVariant = 1; signVariant <= 2; signVariant++)
{
mutations.Add(new PlayoutSetVisible(
$"barG{participant}_{signVariant}",
false));
}
}
var centerY = minimum >= 0d
? CenterMinimumY
: maximum <= 0d
? CenterMaximumY
: CenterMinimumY + TextHeight +
(Height * Math.Abs(minimum) / (maximum + Math.Abs(minimum)));
mutations.Add(new PlayoutSetPosition(
"centerbar",
0,
Convert.ToInt32(centerY),
0,
PlayoutVectorComponents.Y));
foreach (var item in amounts)
{
var participant = ParticipantIndex(item.Participant);
var signVariant = item.Amount < 0 ? 2 : 1;
var ratio = ScaleRatio(item.Amount, maximum, minimum);
mutations.Add(new PlayoutSetVisible(
$"barG{participant}_{signVariant}",
true));
mutations.Add(new PlayoutSetScale(
$"bar{participant}_{signVariant}",
0,
ratio,
0,
PlayoutVectorComponents.Y));
mutations.Add(new PlayoutSetValue(
$"value{participant}_{signVariant}",
item.Amount.ToString("#,##0", CultureInfo.InvariantCulture)));
}
return mutations;
}
private static int ParticipantIndex(InvestorParticipant participant) => participant switch
{
InvestorParticipant.Individual => 1,
InvestorParticipant.Foreign => 2,
InvestorParticipant.Institution => 3,
_ => throw new LegacySceneDataException("Unknown investor participant.")
};
private static float ScaleRatio(double value, double maximum, double minimum)
{
if (value == 0d)
{
// The legacy expression produces NaN for an all-zero input. Clearing the bar is
// the approved deterministic correction and prevents an invalid SDK call.
return 0f;
}
if (value > 0d)
{
var ratio = value / maximum;
if (minimum < 0d)
{
ratio *= maximum / (maximum + Math.Abs(minimum));
}
return (float)ratio;
}
var negativeRatio = Math.Abs(value) / Math.Abs(minimum);
if (maximum > 0d)
{
negativeRatio *= Math.Abs(minimum) / (maximum + Math.Abs(minimum));
}
return (float)negativeRatio;
}
}
public sealed record ManualNetSellPairData(
string LeftName,
string LeftAmount,
string RightName,
string RightAmount);
public sealed record S5025SceneData(
string Title,
IReadOnlyList<ManualNetSellPairData?> Rows) : ILegacySceneData;
public sealed class S5025SceneMutationBuilder : ILegacySceneMutationBuilder<S5025SceneData>
{
private const int CompatibilityPairRowCount = 4;
private const int LegacyPairRowCount = 5;
public string BuilderKey => "s5025";
public IReadOnlyList<PlayoutMutation> Build(S5025SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
// The original .dat contract contains five pair rows plus its trailing blank
// line and therefore writes slots 1..10. Four rows remain accepted only for
// DTO compatibility with cues created before that source contract was traced.
var rows = LegacySceneBuilderGuard.Range(
data.Rows,
CompatibilityPairRowCount,
LegacyPairRowCount,
nameof(data.Rows));
var normalized = rows.Select((row, index) => Normalize(row, index)).ToArray();
var mutations = new List<PlayoutMutation>(1 + (normalized.Length * 4))
{
new PlayoutSetValue("title", title + " \uC21C\uB9E4\uB3C4 \uC0C1\uC704")
};
for (var rowIndex = 0; rowIndex < normalized.Length; rowIndex++)
{
var firstSlot = (rowIndex * 2) + 1;
var row = normalized[rowIndex];
mutations.Add(new PlayoutSetValue("\uC885\uBAA9" + firstSlot, row.LeftName));
mutations.Add(new PlayoutSetValue("\uAE08\uC561" + firstSlot, row.LeftAmount));
mutations.Add(new PlayoutSetValue("\uC885\uBAA9" + (firstSlot + 1), row.RightName));
mutations.Add(new PlayoutSetValue("\uAE08\uC561" + (firstSlot + 1), row.RightAmount));
}
return mutations;
}
private static ManualNetSellPairData Normalize(ManualNetSellPairData? row, int index)
{
if (row is null)
{
return new ManualNetSellPairData(string.Empty, string.Empty, string.Empty, string.Empty);
}
return new ManualNetSellPairData(
LegacySceneBuilderGuard.Text(row.LeftName, $"Rows[{index}].LeftName", true),
LegacySceneBuilderGuard.Text(row.LeftAmount, $"Rows[{index}].LeftAmount", true),
LegacySceneBuilderGuard.Text(row.RightName, $"Rows[{index}].RightName", true),
LegacySceneBuilderGuard.Text(row.RightAmount, $"Rows[{index}].RightAmount", true));
}
}
public sealed record DealerVolumeData(string DealerName, long Volume);
public sealed record S5037SceneData(
string StockName,
long CurrentPrice,
long ChangePrice,
double Rate,
ScenePriceDirection Direction,
IReadOnlyList<DealerVolumeData> BuyDealers,
IReadOnlyList<DealerVolumeData> SellDealers) : ILegacySceneData;
public sealed class S5037SceneMutationBuilder : ILegacySceneMutationBuilder<S5037SceneData>
{
public string BuilderKey => "s5037";
public IReadOnlyList<PlayoutMutation> Build(S5037SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
if (!double.IsFinite(data.Rate))
{
throw new LegacySceneDataException("Dealer quote rate must be finite.");
}
var buys = NormalizeDealers(data.BuyDealers, nameof(data.BuyDealers));
var sells = NormalizeDealers(data.SellDealers, nameof(data.SellDealers));
var mutations = new List<PlayoutMutation>(44)
{
new PlayoutSetValue("title", stockName),
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))
};
LegacySimpleQuoteMutations.AddDirection(mutations, data.Direction);
for (var slot = 1; slot <= 5; slot++)
{
mutations.Add(new PlayoutSetValue("buy_title_" + slot, string.Empty));
mutations.Add(new PlayoutSetValue("buy_vol_" + slot, string.Empty));
mutations.Add(new PlayoutSetValue("sell_title_" + slot, string.Empty));
mutations.Add(new PlayoutSetValue("sell_vol_" + slot, string.Empty));
}
for (var index = 0; index < buys.Count; index++)
{
var slot = index + 1;
mutations.Add(new PlayoutSetValue("buy_title_" + slot, buys[index].DealerName));
mutations.Add(new PlayoutSetValue("buy_vol_" + slot, FormatInteger(buys[index].Volume)));
}
for (var index = 0; index < sells.Count; index++)
{
var slot = index + 1;
mutations.Add(new PlayoutSetValue("sell_title_" + slot, sells[index].DealerName));
mutations.Add(new PlayoutSetValue("sell_vol_" + slot, FormatInteger(sells[index].Volume)));
}
return mutations;
}
private static IReadOnlyList<DealerVolumeData> NormalizeDealers(
IReadOnlyList<DealerVolumeData>? dealers,
string name)
{
var values = LegacySceneBuilderGuard.Range(dealers, 0, 5, name);
return values.Select((dealer, index) =>
{
var value = LegacySceneBuilderGuard.NotNull(dealer, $"{name}[{index}]");
return value with
{
DealerName = LegacySceneBuilderGuard.Text(
value.DealerName,
$"{name}[{index}].DealerName")
};
}).ToArray();
}
private static string FormatInteger(long value) =>
value.ToString("#,##0", CultureInfo.InvariantCulture);
}
public sealed record OrderBookLevelData(long Price, long Quantity);
public sealed record S8003SceneData(
string StockName,
long CurrentPrice,
long ChangePrice,
double Rate,
ScenePriceDirection Direction,
IReadOnlyList<OrderBookLevelData?> BuyLevels,
IReadOnlyList<OrderBookLevelData?> SellLevels,
long TotalBuyQuantity,
long TotalSellQuantity) : ILegacySceneData;
public sealed class S8003SceneMutationBuilder : ILegacySceneMutationBuilder<S8003SceneData>
{
private const int LevelCount = 3;
private static readonly string[] BuyPriceObjects =
["price_buy_1", "price_buy_2", "price_buy_3"];
private static readonly string[] BuyQuantityObjects =
["buy_jan_1", "buy_jan_2", "buy_jan_3"];
private static readonly string[] BuyBarObjects =
["buy_bar_1", "buy_bar_2", "buy_bar_3"];
private static readonly string[] SellPriceObjects =
["price_sell_1", "price_sell_2", "price_sell_3"];
private static readonly string[] SellQuantityObjects =
["sell_jan_1", "sell_jan_2", "sell_jan_3"];
private static readonly string[] SellBarObjects =
["sell_bar_1", "sell_bar_2", "sell_bar_3"];
public string BuilderKey => "s8003";
public IReadOnlyList<PlayoutMutation> Build(S8003SceneData data)
{
ArgumentNullException.ThrowIfNull(data);
var stockName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
if (!double.IsFinite(data.Rate))
{
throw new LegacySceneDataException("Order-book quote rate must be finite.");
}
if (data.TotalBuyQuantity < 0 || data.TotalSellQuantity < 0)
{
throw new LegacySceneDataException("Order-book total quantities cannot be negative.");
}
var buys = NormalizeLevels(data.BuyLevels, nameof(data.BuyLevels));
var sells = NormalizeLevels(data.SellLevels, nameof(data.SellLevels));
var mutations = new List<PlayoutMutation>(40)
{
new PlayoutSetValue("title", stockName),
new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"),
new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)),
new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)),
new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture))
};
LegacySimpleQuoteMutations.AddDirection(mutations, data.Direction);
AddPrices(mutations, BuyPriceObjects, buys, data.CurrentPrice);
AddQuantities(mutations, BuyQuantityObjects, buys);
AddPrices(mutations, SellPriceObjects, sells, data.CurrentPrice);
AddQuantities(mutations, SellQuantityObjects, sells);
mutations.Add(new PlayoutSetValue("buy_jan_total", FormatInteger(data.TotalBuyQuantity)));
mutations.Add(new PlayoutSetValue("sell_jan_total", FormatInteger(data.TotalSellQuantity)));
var maximumQuantity = buys.Concat(sells)
.Where(level => level is not null)
.Select(level => level!.Quantity)
.DefaultIfEmpty(0)
.Max();
AddBarScales(mutations, BuyBarObjects, buys, maximumQuantity);
AddBarScales(mutations, SellBarObjects, sells, maximumQuantity);
return mutations;
}
private static IReadOnlyList<OrderBookLevelData?> NormalizeLevels(
IReadOnlyList<OrderBookLevelData?>? levels,
string name)
{
var values = LegacySceneBuilderGuard.Count(levels, LevelCount, name);
if (values.Any(level => level?.Quantity < 0))
{
throw new LegacySceneDataException("Order-book quantities cannot be negative.");
}
return values;
}
private static void AddPrices(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<string> objects,
IReadOnlyList<OrderBookLevelData?> levels,
long currentPrice)
{
for (var index = 0; index < LevelCount; index++)
{
var level = levels[index];
mutations.Add(new PlayoutSetValue(
objects[index],
level is null ? string.Empty : FormatInteger(level.Price)));
var color = level is null || level.Price == currentPrice
? (R: 50, G: 51, B: 50)
: currentPrice < level.Price
? (R: 172, G: 0, B: 0)
: (R: 19, G: 73, B: 170);
mutations.Add(new PlayoutSetFaceColor(
objects[index],
color.R,
color.G,
color.B));
}
}
private static void AddQuantities(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<string> objects,
IReadOnlyList<OrderBookLevelData?> levels)
{
for (var index = 0; index < LevelCount; index++)
{
mutations.Add(new PlayoutSetValue(
objects[index],
levels[index] is null ? string.Empty : FormatInteger(levels[index]!.Quantity)));
}
}
private static void AddBarScales(
ICollection<PlayoutMutation> mutations,
IReadOnlyList<string> objects,
IReadOnlyList<OrderBookLevelData?> levels,
long maximumQuantity)
{
for (var index = 0; index < LevelCount; index++)
{
var scale = maximumQuantity == 0 || levels[index] is null
? 0f
: (float)(levels[index]!.Quantity / (double)maximumQuantity);
mutations.Add(new PlayoutSetScale(
objects[index],
scale,
0,
0,
PlayoutVectorComponents.X));
}
}
private static string FormatInteger(long value) =>
value.ToString("#,##0", CultureInfo.InvariantCulture);
}
internal static class LegacySimpleQuoteMutations
{
private static readonly string[] DirectionObjects =
["upup", "up", "flat", "down", "downdown"];
public static void AddDirection(
ICollection<PlayoutMutation> mutations,
ScenePriceDirection direction)
{
foreach (var objectName in DirectionObjects)
{
mutations.Add(new PlayoutSetVisible(objectName, false));
}
string? active = direction switch
{
ScenePriceDirection.LimitUp => "upup",
ScenePriceDirection.Up => "up",
ScenePriceDirection.Down => "down",
ScenePriceDirection.LimitDown => "downdown",
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => null,
_ => throw new LegacySceneDataException("Unknown price direction.")
};
if (active is not null)
{
mutations.Add(new PlayoutSetVisible(active, true));
}
var variant = direction switch
{
ScenePriceDirection.LimitUp or ScenePriceDirection.Up => 1,
ScenePriceDirection.Down or ScenePriceDirection.LimitDown => 3,
ScenePriceDirection.Flat or ScenePriceDirection.Unknown => 2,
_ => throw new LegacySceneDataException("Unknown price direction.")
};
for (var index = 1; index <= 3; index++)
{
mutations.Add(new PlayoutSetVisible("bg" + index, index == variant));
}
}
}

View File

@@ -0,0 +1,300 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record S5037SceneLoadRequest(
LegacyDomesticMarket Market,
string StockName);
public sealed class S5037SceneDataLoader
{
private static readonly string[] QuoteColumns =
["STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE"];
private static readonly string[] DealerColumns = ["DEALER_NAME", "VOLUME"];
private readonly IDataQueryExecutor _executor;
public S5037SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S5037SceneData> LoadAsync(
S5037SceneLoadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var stockName = LegacyTabularSceneLoaderReader.Selector(request.StockName);
var plan = TraderQuoteSceneQueries.S5037(request.Market, stockName);
var quoteTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5037_QUOTE",
plan.Quote,
cancellationToken).ConfigureAwait(false);
var buyTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5037_BUY",
plan.Buy,
cancellationToken).ConfigureAwait(false);
var sellTable = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_5037_SELL",
plan.Sell,
cancellationToken).ConfigureAwait(false);
var quote = LegacyTabularSceneLoaderReader.ExactRows(quoteTable, 1, QuoteColumns)[0];
var returnedName = LegacyTabularSceneLoaderReader.Text(quote, "STOCK_NAME");
if (!string.Equals(returnedName, stockName, StringComparison.Ordinal))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
var data = new S5037SceneData(
returnedName,
LegacyTabularSceneLoaderReader.Int64(quote, "CURRENT_PRICE"),
LegacyTabularSceneLoaderReader.Int64(quote, "CHANGE_PRICE"),
LegacyTabularSceneLoaderReader.Double(quote, "RATE"),
LegacyTabularSceneLoaderReader.Direction(quote, "DIRECTION_CODE"),
MapDealers(buyTable),
MapDealers(sellTable));
return LegacyTabularSceneLoaderReader.Preflight(new S5037SceneMutationBuilder(), data);
}
private static IReadOnlyList<DealerVolumeData> MapDealers(DataTable? table)
{
var rows = LegacyTabularSceneLoaderReader.ExactRows(table, 5, DealerColumns);
var dealers = new DealerVolumeData[rows.Count];
long? previousVolume = null;
for (var index = 0; index < rows.Count; index++)
{
var volume = LegacyTabularSceneLoaderReader.Int64(rows[index], "VOLUME");
if (previousVolume.HasValue && volume > previousVolume.Value)
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
previousVolume = volume;
dealers[index] = new DealerVolumeData(
LegacyTabularSceneLoaderReader.Text(rows[index], "DEALER_NAME"),
volume);
}
return dealers;
}
}
public sealed record S8003SceneLoadRequest(
LegacyDomesticMarket Market,
string StockCode);
public sealed class S8003SceneDataLoader
{
private static readonly string[] Columns =
[
"STOCK_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "DIRECTION_CODE", "RATE",
"SELL_PRICE_1", "SELL_PRICE_2", "SELL_PRICE_3",
"SELL_QUANTITY_1", "SELL_QUANTITY_2", "SELL_QUANTITY_3",
"BUY_PRICE_1", "BUY_PRICE_2", "BUY_PRICE_3",
"BUY_QUANTITY_1", "BUY_QUANTITY_2", "BUY_QUANTITY_3",
"TOTAL_SELL_QUANTITY", "TOTAL_BUY_QUANTITY"
];
private readonly IDataQueryExecutor _executor;
public S8003SceneDataLoader(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<S8003SceneData> LoadAsync(
S8003SceneLoadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var stockCode = LegacyTabularSceneLoaderReader.StockCode(request.StockCode);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"SCENE_8003",
TraderQuoteSceneQueries.S8003(request.Market, stockCode),
cancellationToken).ConfigureAwait(false);
var row = LegacyTabularSceneLoaderReader.ExactRows(table, 1, Columns)[0];
var data = new S8003SceneData(
LegacyTabularSceneLoaderReader.Text(row, "STOCK_NAME"),
LegacyTabularSceneLoaderReader.Int64(row, "CURRENT_PRICE"),
LegacyTabularSceneLoaderReader.Int64(row, "CHANGE_PRICE"),
LegacyTabularSceneLoaderReader.Double(row, "RATE"),
LegacyTabularSceneLoaderReader.Direction(row, "DIRECTION_CODE"),
Levels(row, "BUY"),
Levels(row, "SELL"),
LegacyTabularSceneLoaderReader.Int64(row, "TOTAL_BUY_QUANTITY"),
LegacyTabularSceneLoaderReader.Int64(row, "TOTAL_SELL_QUANTITY"));
return LegacyTabularSceneLoaderReader.Preflight(new S8003SceneMutationBuilder(), data);
}
private static IReadOnlyList<OrderBookLevelData?> Levels(DataRow row, string side)
{
var levels = new OrderBookLevelData?[3];
for (var index = 0; index < levels.Length; index++)
{
var number = index + 1;
levels[index] = new OrderBookLevelData(
LegacyTabularSceneLoaderReader.Int64(row, $"{side}_PRICE_{number}"),
LegacyTabularSceneLoaderReader.Int64(row, $"{side}_QUANTITY_{number}"));
}
return levels;
}
}
internal static class TraderQuoteSceneQueries
{
public static TraderQueryPlan S5037(LegacyDomesticMarket market, string stockName)
{
var source = market switch
{
LegacyDomesticMarket.Kospi => new TraderSource(
"t_online1", "t_stock", "t_dealer"),
LegacyDomesticMarket.Kosdaq => new TraderSource(
"t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_dealer"),
_ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
};
var quote = LegacyTabularSceneLoaderReader.Spec(
DataSourceKind.Oracle,
$"""
SELECT
b.f_stock_wanname STOCK_NAME,
a.f_curr_price CURRENT_PRICE,
a.f_net_chg CHANGE_PRICE,
a.f_chg_type DIRECTION_CODE,
ROUND((a.f_net_chg / DECODE(
a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE
FROM {source.OnlineTable} a
JOIN {source.StockTable} b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
AND b.f_stock_wanname = :stockName
""",
new DataQueryParameter("stockName", stockName, DbType.String));
return new TraderQueryPlan(
quote,
Dealer(source, stockName, buy: true),
Dealer(source, stockName, buy: false));
}
public static DataQuerySpec S8003(LegacyDomesticMarket market, string stockCode)
{
var source = market switch
{
LegacyDomesticMarket.Kospi => new QuoteSource(
"t_online1", "t_stock", "t_online1_call"),
LegacyDomesticMarket.Kosdaq => new QuoteSource(
"t_kosdaq_online1", "t_kosdaq_stock", "t_kosdaq_online1_call"),
_ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
};
return LegacyTabularSceneLoaderReader.Spec(
DataSourceKind.Oracle,
$"""
SELECT
b.f_stock_wanname STOCK_NAME,
a.f_curr_price CURRENT_PRICE,
a.f_net_chg CHANGE_PRICE,
a.f_chg_type DIRECTION_CODE,
ROUND((a.f_net_chg / DECODE(
a.f_chg_type,
'1', a.f_curr_price - a.f_net_chg,
'2', a.f_curr_price - a.f_net_chg,
'3', a.f_curr_price,
'4', -1 * (a.f_curr_price + a.f_net_chg),
'5', -1 * (a.f_curr_price + a.f_net_chg))) * 100, 2) RATE,
c.f_call_sell_1 SELL_PRICE_1,
c.f_call_sell_2 SELL_PRICE_2,
c.f_call_sell_3 SELL_PRICE_3,
c.f_call_sell_jan_1 SELL_QUANTITY_1,
c.f_call_sell_jan_2 SELL_QUANTITY_2,
c.f_call_sell_jan_3 SELL_QUANTITY_3,
c.f_call_buy_1 BUY_PRICE_1,
c.f_call_buy_2 BUY_PRICE_2,
c.f_call_buy_3 BUY_PRICE_3,
c.f_call_buy_jan_1 BUY_QUANTITY_1,
c.f_call_buy_jan_2 BUY_QUANTITY_2,
c.f_call_buy_jan_3 BUY_QUANTITY_3,
c.f_t_call_sell_jan TOTAL_SELL_QUANTITY,
c.f_t_call_buy_jan TOTAL_BUY_QUANTITY
FROM {source.OnlineTable} a
JOIN {source.StockTable} b ON a.f_stock_code = b.f_stock_code
JOIN {source.CallTable} c ON a.f_stock_code = c.f_stock_code
WHERE a.f_mkt_halt = 'N'
AND a.f_curr_price > 0
AND a.f_stock_code = :stockCode
""",
new DataQueryParameter("stockCode", stockCode, DbType.String));
}
private static DataQuerySpec Dealer(TraderSource source, string stockName, bool buy)
{
var side = buy ? "buy" : "sell";
return LegacyTabularSceneLoaderReader.Spec(
DataSourceKind.Oracle,
$"""
WITH target_stock AS (
SELECT f_stock_code
FROM {source.StockTable}
WHERE f_stock_wanname = :stockName
AND f_mkt_halt = 'N'
), dealer_rows AS (
SELECT 1 slot_order, b.f_sale_nick_kname dealer_name,
a.f_{side}_dealer_vol_1 volume
FROM {source.DealerTable} a
JOIN target_stock c ON a.f_stock_code = c.f_stock_code
JOIN t_sale_member b ON a.f_{side}_dealer_no_1 = b.f_sale_no
UNION ALL
SELECT 2, b.f_sale_nick_kname, a.f_{side}_dealer_vol_2
FROM {source.DealerTable} a
JOIN target_stock c ON a.f_stock_code = c.f_stock_code
JOIN t_sale_member b ON a.f_{side}_dealer_no_2 = b.f_sale_no
UNION ALL
SELECT 3, b.f_sale_nick_kname, a.f_{side}_dealer_vol_3
FROM {source.DealerTable} a
JOIN target_stock c ON a.f_stock_code = c.f_stock_code
JOIN t_sale_member b ON a.f_{side}_dealer_no_3 = b.f_sale_no
UNION ALL
SELECT 4, b.f_sale_nick_kname, a.f_{side}_dealer_vol_4
FROM {source.DealerTable} a
JOIN target_stock c ON a.f_stock_code = c.f_stock_code
JOIN t_sale_member b ON a.f_{side}_dealer_no_4 = b.f_sale_no
UNION ALL
SELECT 5, b.f_sale_nick_kname, a.f_{side}_dealer_vol_5
FROM {source.DealerTable} a
JOIN target_stock c ON a.f_stock_code = c.f_stock_code
JOIN t_sale_member b ON a.f_{side}_dealer_no_5 = b.f_sale_no
)
SELECT dealer_name DEALER_NAME, volume VOLUME
FROM dealer_rows
ORDER BY volume DESC, slot_order
""",
new DataQueryParameter("stockName", stockName, DbType.String));
}
internal sealed record TraderQueryPlan(
DataQuerySpec Quote,
DataQuerySpec Buy,
DataQuerySpec Sell);
private sealed record TraderSource(
string OnlineTable,
string StockTable,
string DealerTable);
private sealed record QuoteSource(
string OnlineTable,
string StockTable,
string CallTable);
}

View File

@@ -0,0 +1,91 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public enum S5025ManualAudience
{
Individual,
Foreign,
Institution
}
public sealed record S5025ManualSceneLoadRequest(S5025ManualAudience Audience);
public sealed record S5025TrustedManualRow(
string LeftName,
string LeftAmount,
string RightName,
string RightAmount);
/// <summary>
/// Composition-root contract for the approved manual-data store. It deliberately
/// exposes a closed audience key and no path so a WebView request cannot choose a file.
/// </summary>
public interface IS5025TrustedManualDataSource
{
Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default);
}
public sealed class S5025SceneDataLoader
{
private const int RowCount = 5;
private const int MaximumCellLength = 256;
private readonly IS5025TrustedManualDataSource _source;
public S5025SceneDataLoader(IS5025TrustedManualDataSource source)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
}
public async Task<S5025SceneData> LoadAsync(
S5025ManualSceneLoadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var title = request.Audience switch
{
S5025ManualAudience.Individual => "\uAC1C\uC778",
S5025ManualAudience.Foreign => "\uC678\uAD6D\uC778",
S5025ManualAudience.Institution => "\uAE30\uAD00",
_ => throw LegacyTabularSceneLoaderReader.InvalidRequest()
};
var sourceRows = await _source.ReadAsync(request.Audience, cancellationToken)
.ConfigureAwait(false);
if (sourceRows is null || sourceRows.Count != RowCount || sourceRows.Any(row => row is null))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
var rows = sourceRows.Select((row, index) => new ManualNetSellPairData(
Cell(row.LeftName, index, nameof(row.LeftName)),
Cell(row.LeftAmount, index, nameof(row.LeftAmount)),
Cell(row.RightName, index, nameof(row.RightName)),
Cell(row.RightAmount, index, nameof(row.RightAmount))))
.ToArray();
var data = new S5025SceneData(title, rows);
return LegacyTabularSceneLoaderReader.Preflight(new S5025SceneMutationBuilder(), data);
}
private static string Cell(string? value, int row, string field)
{
if (value is null || value.Length > MaximumCellLength || value.Any(char.IsControl))
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
try
{
return LegacySceneBuilderGuard.Text(
value,
$"Rows[{row}].{field}",
allowEmpty: true);
}
catch (LegacySceneDataException)
{
throw LegacyTabularSceneLoaderReader.InvalidResult();
}
}
}

View File

@@ -35,7 +35,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
.GetAwaiter()
.GetResult();
public async Task<DataTable> ExecuteAsync(
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
@@ -43,6 +43,38 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
return ExecuteAsyncCore(
source,
tableName,
query,
parameters: null,
cancellationToken);
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
query.ValidateFor(source);
return ExecuteAsyncCore(
source,
tableName,
query.Sql,
query.Parameters,
cancellationToken);
}
private async Task<DataTable> ExecuteAsyncCore(
DataSourceKind source,
string tableName,
string query,
IReadOnlyList<DataQueryParameter>? parameters,
CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
@@ -52,12 +84,19 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
{
try
{
return await ExecuteAttemptAsync(source, tableName, query, operationToken)
return await ExecuteAttemptAsync(
source,
tableName,
query,
parameters,
operationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
throw new OperationCanceledException(
"The database query was canceled.",
cancellationToken);
}
catch (OperationCanceledException)
{
@@ -98,6 +137,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
DataSourceKind source,
string tableName,
string query,
IReadOnlyList<DataQueryParameter>? parameters,
CancellationToken cancellationToken)
{
await using var connection = _connectionFactory.Create(source);
@@ -108,6 +148,23 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
command.CommandType = CommandType.Text;
command.CommandTimeout = _connectionFactory.GetCommandTimeoutSeconds(source);
if (parameters is not null)
{
foreach (var parameterSpec in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
if (parameterSpec.DbType.HasValue)
{
parameter.DbType = parameterSpec.DbType.Value;
}
command.Parameters.Add(parameter);
}
}
await using var reader = await command
.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken)
.ConfigureAwait(false);
@@ -218,15 +275,31 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
"INSERT",
"UPDATE",
"DELETE",
"REPLACE",
"MERGE",
"CALL",
"EXEC",
"EXECUTE",
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"GRANT",
"REVOKE"
"REVOKE",
"INTO",
"LOCK",
"SET",
"USE",
"DO",
"LOAD",
"BEGIN",
"DECLARE",
"COMMIT",
"ROLLBACK",
"SAVEPOINT",
"FUNCTION",
"PROCEDURE",
"PRAGMA"
];
foreach (var token in stateChangingTokens)

View File

@@ -0,0 +1,317 @@
using System.Security;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Raised when the trusted s5025 manual-data boundary cannot prove that its
/// configuration or input file satisfies the closed production contract.
/// The exception deliberately does not retain filesystem exceptions, which can
/// contain the configured external path.
/// </summary>
public sealed class S5025TrustedManualDataException : Exception
{
internal S5025TrustedManualDataException()
: base("The trusted s5025 manual-data source is unavailable or invalid.")
{
}
}
/// <summary>
/// Reads the three legacy CP949 s5025 manual-data files from one directory
/// selected only by the application composition root. Callers select a closed
/// audience value and can never supply a path or filename.
/// </summary>
public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSource
{
public const string DirectoryEnvironmentVariable =
"MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY";
public const int MaximumFileSizeBytes = 32 * 1024;
private const int ExpectedRowCount = 5;
private const int ExpectedColumnCount = 4;
private const int MaximumCellLength = 256;
private static readonly Encoding Cp949Encoding = CreateCp949Encoding();
private readonly string _trustedDirectory;
private readonly StringComparison _pathComparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
/// <summary>
/// Creates the source from an explicit composition-root setting. The
/// directory must already exist and be an absolute, non-reparse path.
/// </summary>
public S5025TrustedManualFileDataSource(string trustedDirectory)
{
_trustedDirectory = NormalizeAndValidateTrustedDirectory(trustedDirectory);
}
/// <summary>
/// Creates the source from the process environment. The injectable reader
/// is intended for composition-root tests and does not alter the closed
/// runtime data-source contract.
/// </summary>
public static S5025TrustedManualFileDataSource CreateFromEnvironment(
Func<string, string?>? readEnvironmentVariable = null)
{
var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
return new S5025TrustedManualFileDataSource(
read(DirectoryEnvironmentVariable) ?? string.Empty);
}
public async Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
try
{
EnsureDirectoryIsTrusted(_trustedDirectory);
var path = GetContainedFilePath(fileName);
EnsureRegularNonReparseFile(path);
byte[] bytes;
await using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
if (stream.Length <= 0 || stream.Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
bytes = new byte[checked((int)stream.Length)];
var offset = 0;
while (offset < bytes.Length)
{
var read = await stream.ReadAsync(
bytes.AsMemory(offset),
cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw InvalidData();
}
offset += read;
}
if (await stream.ReadAsync(
new byte[1],
cancellationToken).ConfigureAwait(false) != 0)
{
throw InvalidData();
}
}
// Re-check the closed path after reading. FileShare.Read prevents a
// conforming Windows writer from replacing or modifying the file
// while the handle is open; this second check also detects a path
// replacement immediately after close.
EnsureDirectoryIsTrusted(_trustedDirectory);
EnsureRegularNonReparseFile(path);
return Parse(bytes);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException or
DecoderFallbackException)
{
throw InvalidData();
}
}
private string GetContainedFilePath(string fileName)
{
var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName));
var prefix = Path.EndsInDirectorySeparator(_trustedDirectory)
? _trustedDirectory
: _trustedDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(prefix, _pathComparison))
{
throw InvalidData();
}
var relative = Path.GetRelativePath(_trustedDirectory, candidate);
if (Path.IsPathRooted(relative)
|| relative.Equals("..", _pathComparison)
|| relative.StartsWith(".." + Path.DirectorySeparatorChar, _pathComparison)
|| relative.StartsWith(".." + Path.AltDirectorySeparatorChar, _pathComparison)
|| !relative.Equals(fileName, _pathComparison))
{
throw InvalidData();
}
return candidate;
}
private static IReadOnlyList<S5025TrustedManualRow> Parse(byte[] bytes)
{
RejectUnicodeBom(bytes);
string text;
try
{
text = Cp949Encoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidData();
}
var lines = new List<string>(ExpectedRowCount);
using (var reader = new StringReader(text))
{
while (reader.ReadLine() is { } line)
{
lines.Add(line.Trim());
}
}
// The approved legacy files contain five data rows followed by one empty
// terminal record. s5025 intentionally rendered `row - 1`, thereby
// ignoring exactly that record. Preserve that contract without accepting
// internal blank rows or an arbitrary number of trailing records.
if (lines.Count == ExpectedRowCount + 1 && lines[^1].Length == 0)
{
lines.RemoveAt(lines.Count - 1);
}
if (lines.Count != ExpectedRowCount)
{
throw InvalidData();
}
var rows = new S5025TrustedManualRow[ExpectedRowCount];
for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++)
{
var columns = lines[rowIndex].Split('^');
if (columns.Length != ExpectedColumnCount
|| columns.Any(column =>
column.Length > MaximumCellLength || column.Any(char.IsControl)))
{
throw InvalidData();
}
rows[rowIndex] = new S5025TrustedManualRow(
columns[0],
columns[1],
columns[2],
columns[3]);
}
return rows;
}
private static string NormalizeAndValidateTrustedDirectory(string trustedDirectory)
{
if (string.IsNullOrWhiteSpace(trustedDirectory)
|| trustedDirectory.IndexOf('\0') >= 0
|| !Path.IsPathFullyQualified(trustedDirectory))
{
throw InvalidData();
}
try
{
var fullPath = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(trustedDirectory));
EnsureDirectoryIsTrusted(fullPath);
return fullPath;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException)
{
throw InvalidData();
}
}
private static void EnsureDirectoryIsTrusted(string directory)
{
var current = new DirectoryInfo(directory);
while (current is not null)
{
var attributes = File.GetAttributes(current.FullName);
if ((attributes & FileAttributes.Directory) == 0
|| (attributes & FileAttributes.ReparsePoint) != 0)
{
throw InvalidData();
}
current = current.Parent;
}
}
private static void EnsureRegularNonReparseFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) != 0
|| (attributes & FileAttributes.ReparsePoint) != 0)
{
throw InvalidData();
}
var length = new FileInfo(path).Length;
if (length <= 0 || length > MaximumFileSizeBytes)
{
throw InvalidData();
}
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static void RejectUnicodeBom(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })
|| bytes.StartsWith(new byte[] { 0xFF, 0xFE })
|| bytes.StartsWith(new byte[] { 0xFE, 0xFF })
|| bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF }))
{
throw InvalidData();
}
}
private static S5025TrustedManualDataException InvalidData() => new();
}

View File

@@ -1,4 +1,5 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -20,6 +21,22 @@ public sealed class PlayoutOptions
public int LayoutIndex { get; set; } = 10;
/// <summary>MainForm ComboDi.SelectedIndex default passed to SetSceneEffectType.</summary>
public int LegacySceneFadeDuration { get; set; } = 6;
/// <summary>
/// Trusted MainForm-level background selection. The asset is always a path relative
/// to SceneDirectory and is never accepted from Web content.
/// </summary>
public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } =
LegacySceneBackgroundKind.None;
public string? LegacySceneBackgroundAssetPath { get; set; }
public int LegacySceneBackgroundVideoLoopCount { get; set; } = 2004;
public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true;
/// <summary>
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
/// </summary>
@@ -27,6 +44,10 @@ public sealed class PlayoutOptions
public string? TestProcessWindowTitlePattern { get; set; }
/// <summary>
/// Closed scene-name allowlist for every real Test or Live output command.
/// The property name is retained for existing local configuration files.
/// </summary>
public List<string> TestSceneAllowlist { get; set; } = [];
public bool TrustedLiveOutputEnabled { get; set; }

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -73,13 +74,30 @@ public static class PlayoutOptionsLoader
private static void ApplyEnvironment(PlayoutOptions options)
{
ApplyEnum("MBN_STOCK_PLAYOUT_MODE", value => options.Mode = value);
ApplyEnum(
"MBN_STOCK_PLAYOUT_MODE",
(PlayoutMode value) => options.Mode = value);
ApplyString("MBN_STOCK_PLAYOUT_HOST", value => options.Host = value);
ApplyInt("MBN_STOCK_PLAYOUT_PORT", value => options.Port = value);
ApplyInt("MBN_STOCK_PLAYOUT_TCP_MODE", value => options.TcpMode = value);
ApplyInt("MBN_STOCK_PLAYOUT_CLIENT_PORT", value => options.ClientPort = value);
ApplyNullableInt("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", value => options.OutputChannel = value);
ApplyInt("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", value => options.LayoutIndex = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION",
value => options.LegacySceneFadeDuration = value);
ApplyEnum(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND",
(LegacySceneBackgroundKind value) => options.LegacySceneBackgroundKind = value);
ApplyString(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET",
value => options.LegacySceneBackgroundAssetPath = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT",
value => options.LegacySceneBackgroundVideoLoopCount = value);
ApplyBool(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
value => options.LegacySceneBackgroundVideoLoopInfinite = value);
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
ApplyString(
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
@@ -177,7 +195,8 @@ public static class PlayoutOptionsLoader
apply(value);
}
private static void ApplyEnum(string name, Action<PlayoutMode> apply)
private static void ApplyEnum<TEnum>(string name, Action<TEnum> apply)
where TEnum : struct, Enum
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
@@ -185,7 +204,7 @@ public static class PlayoutOptionsLoader
return;
}
if (!Enum.TryParse<PlayoutMode>(text, true, out var value))
if (!Enum.TryParse<TEnum>(text, true, out var value) || !Enum.IsDefined(value))
{
throw InvalidEnvironment(name);
}

View File

@@ -0,0 +1,109 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// <summary>
/// Validates the trusted MainForm-level fade/background configuration before the
/// first DryRun or actual PREPARE. Web content never supplies these values.
/// </summary>
public static class PlayoutSceneCompositionFactory
{
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
], StringComparer.OrdinalIgnoreCase);
public static LegacySceneCueCompositionOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.LegacySceneFadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None)
{
if (!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath))
{
throw Invalid();
}
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
LegacySceneBackgroundKind.None);
}
var rootText = options.SceneDirectory?.Trim();
var relativeText = options.LegacySceneBackgroundAssetPath?.Trim();
if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) ||
string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) ||
relativeText.Contains("..", StringComparison.Ordinal) ||
relativeText.Contains(':') || relativeText.Any(char.IsControl) ||
!AllowedExtensions.Contains(Path.GetExtension(relativeText)))
{
throw Invalid();
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
if (!Directory.Exists(root) || IsReparsePoint(root))
{
throw Invalid();
}
var candidate = Path.GetFullPath(Path.Combine(root, relativeText));
var rootPrefix = root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw Invalid();
}
var normalizedRelative = Path.GetRelativePath(root, candidate);
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
normalizedRelative,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite);
}
private static bool HasReparsePointBetween(string root, string file)
{
if (IsReparsePoint(file))
{
return true;
}
var directory = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
{
if (IsReparsePoint(directory))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch
{
return true;
}
}
private static PlayoutConfigurationException Invalid() => new(
"공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다.");
}

View File

@@ -104,6 +104,11 @@ internal sealed record ValidatedPlayoutOptions(
allowlist.Add(value!);
}
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))
@@ -116,11 +121,6 @@ internal sealed record ValidatedPlayoutOptions(
throw Invalid(nameof(options.OutputChannel));
}
if (allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
var pattern = RequiredText(
options.TestProcessWindowTitlePattern,
nameof(options.TestProcessWindowTitlePattern));
@@ -170,6 +170,13 @@ internal sealed record ValidatedPlayoutOptions(
}
public bool IsTestSceneAllowed(PlayoutCue cue)
=> IsOutputSceneAllowed(cue);
/// <summary>
/// Closed scene allowlist applied to every real Test or Live mutation command.
/// The legacy property name is retained for local-config compatibility.
/// </summary>
public bool IsOutputSceneAllowed(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
@@ -200,6 +207,8 @@ internal sealed record ValidatedPlayoutOptions(
}
}
var mutations = ResolveMutations(cue.Mutations);
var relativePath = cue.SceneFile?.Trim();
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
{
@@ -242,10 +251,226 @@ internal sealed record ValidatedPlayoutOptions(
return cue with
{
SceneFile = candidate,
SceneName = sceneName!
SceneName = sceneName!,
Mutations = mutations
};
}
private IReadOnlyList<PlayoutMutation>? ResolveMutations(
IReadOnlyList<PlayoutMutation>? mutations)
{
if (mutations is null)
{
return null;
}
if (mutations.Count > 100_000)
{
throw new PlayoutRequestException("장면 변경 항목이 너무 많습니다.");
}
var resolved = new PlayoutMutation[mutations.Count];
for (var index = 0; index < mutations.Count; index++)
{
resolved[index] = ResolveMutation(mutations[index]);
}
return resolved;
}
private PlayoutMutation ResolveMutation(PlayoutMutation? mutation)
{
if (mutation is null)
{
throw new PlayoutRequestException("장면 변경 항목이 올바르지 않습니다.");
}
switch (mutation)
{
case PlayoutSetValue value:
ValidateObjectName(value.ObjectName);
ValidateText(value.Value);
return value;
case PlayoutSetAssetValue asset:
ValidateObjectName(asset.ObjectName);
return asset with { AssetPath = ResolveAssetPath(asset.AssetPath) };
case PlayoutSetVisible visible:
ValidateObjectName(visible.ObjectName);
return visible;
case PlayoutSetFaceColor color:
ValidateObjectName(color.ObjectName);
if (!IsByte(color.Red) || !IsByte(color.Green) ||
!IsByte(color.Blue) || !IsByte(color.Alpha))
{
throw new PlayoutRequestException("장면 색상 값이 올바르지 않습니다.");
}
return color;
case PlayoutSetPosition position:
ValidateObjectName(position.ObjectName);
ValidatePoint(position.X, position.Y, position.Z);
ValidateVectorComponents(position.Components);
return position;
case PlayoutSetPositionKey positionKey:
ValidateObjectName(positionKey.ObjectName);
ValidateKeyIndex(positionKey.KeyIndex);
ValidatePoint(positionKey.X, positionKey.Y, positionKey.Z);
ValidateVectorComponents(positionKey.Components);
return positionKey;
case PlayoutSetScale scale:
ValidateObjectName(scale.ObjectName);
ValidatePoint(scale.X, scale.Y, scale.Z);
ValidateVectorComponents(scale.Components);
return scale;
case PlayoutSetCropKey crop:
ValidateObjectName(crop.ObjectName);
ValidateKeyIndex(crop.KeyIndex);
ValidatePoint(crop.Left, crop.Top, crop.Right);
if (!float.IsFinite(crop.Bottom) ||
!Enum.IsDefined(crop.Edges) || crop.Edges == 0)
{
throw new PlayoutRequestException("장면 Crop 값이 올바르지 않습니다.");
}
return crop;
case PlayoutSetCircleAngleKey angle:
ValidateObjectName(angle.ObjectName);
ValidateKeyIndex(angle.KeyIndex);
if (!float.IsFinite(angle.Start) || !float.IsFinite(angle.End) ||
!Enum.IsDefined(angle.Components) || angle.Components == 0)
{
throw new PlayoutRequestException("장면 각도 값이 올바르지 않습니다.");
}
return angle;
case PlayoutSetPathPoints path:
ValidateObjectName(path.ObjectName);
ValidatePoints(path.Points);
return path;
case PlayoutSetPathShapePoints shape:
ValidateObjectName(shape.ObjectName);
ValidatePoints(shape.Points);
return shape;
case PlayoutUseBackground background:
ValidateMutationTiming(background.Timing);
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
{
relativePath = relativePath?.Trim();
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 자산 경로가 올바르지 않습니다.");
}
var extension = Path.GetExtension(relativePath);
string[] allowedExtensions =
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
];
if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(SceneDirectory, candidate))
{
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
}
return candidate;
}
private static void ValidateObjectName(string? objectName)
{
if (string.IsNullOrWhiteSpace(objectName) || objectName.Length > 128 ||
objectName.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 오브젝트 이름이 올바르지 않습니다.");
}
}
private static void ValidateText(string? value)
{
if (value is null || value.Length > 16_384 || value.Any(character =>
character is '\0' or '\u0001' or '\u0002' or '\u0003'))
{
throw new PlayoutRequestException("장면 텍스트 값이 올바르지 않습니다.");
}
}
private static void ValidatePoint(float x, float y, float z)
{
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z))
{
throw new PlayoutRequestException("장면 좌표 값이 올바르지 않습니다.");
}
}
private static void ValidatePoints(IReadOnlyList<PlayoutPoint>? points)
{
if (points is null || points.Count > 10_000)
{
throw new PlayoutRequestException("장면 그래프 점 목록이 올바르지 않습니다.");
}
foreach (var point in points)
{
ValidatePoint(point.X, point.Y, point.Z);
}
}
private static void ValidateVectorComponents(PlayoutVectorComponents components)
{
if (!Enum.IsDefined(components) || components == 0)
{
throw new PlayoutRequestException("장면 벡터 축 값이 올바르지 않습니다.");
}
}
private static void ValidateKeyIndex(int keyIndex)
{
if (keyIndex is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 키 인덱스가 올바르지 않습니다.");
}
}
private static void ValidateMutationTiming(PlayoutMutationTiming timing)
{
if (!Enum.IsDefined(timing))
{
throw new PlayoutRequestException("장면 변경 시점이 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)
{
string[] protectedTitles =

View File

@@ -302,6 +302,10 @@ internal static class InstalledK3dInteropMetadata
assembly,
"K3DAsyncEngineLib.KAEventHandlerClass",
K3dComConstants.KaEventHandlerClassGuid);
var eventHandlerInterfaceType = RequireComType(
assembly,
"K3DAsyncEngineLib.KAEventHandler",
Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832"));
var scenePlayerType = RequireComType(
assembly,
"K3DAsyncEngineLib.IKAScenePlayer",
@@ -315,6 +319,22 @@ internal static class InstalledK3dInteropMetadata
"K3DAsyncEngineLib.IKAObject",
Guid.Parse("B138B515-505B-4C7B-A3E1-F82782005ECF"));
var callbackMethods = eventHandlerInterfaceType.GetInterfaces()
.Append(eventHandlerInterfaceType)
.SelectMany(type => type.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
.Distinct()
.ToArray();
if (callbackMethods.Length != 282 ||
callbackMethods.Any(method => method.ReturnType != typeof(void)) ||
!HasCallback(callbackMethods, "OnHello") ||
!HasCallback(callbackMethods, "OnScenePlayed", typeof(int), typeof(int), typeof(int)) ||
!HasCallback(callbackMethods, "OnCutOut", typeof(int), typeof(int), typeof(int)) ||
!HasCallback(callbackMethods, "OnStopAll", typeof(int)))
{
throw new InstalledK3dInteropMetadataUnavailableException();
}
var connectMethod = engineType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.SingleOrDefault(method =>
method.Name == "KTAPConnect" &&
@@ -326,6 +346,7 @@ internal static class InstalledK3dInteropMetadata
parameters[3].ParameterType == typeof(int) &&
parameters[4].ParameterType.GUID ==
Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832") &&
parameters[4].ParameterType == eventHandlerInterfaceType &&
parameters[4].ParameterType.IsAssignableFrom(eventHandlerType));
var disconnectMethod = engineType.GetMethod(
"Disconnect",
@@ -365,6 +386,7 @@ internal static class InstalledK3dInteropMetadata
return new InstalledK3dInteropTypes(
engineType,
eventHandlerType,
eventHandlerInterfaceType,
scenePlayerType,
sceneType,
objectType,
@@ -384,6 +406,14 @@ internal static class InstalledK3dInteropMetadata
return type;
}
private static bool HasCallback(
IEnumerable<MethodInfo> methods,
string name,
params Type[] parameterTypes) => methods.Any(method =>
method.Name == name &&
method.GetParameters().Select(parameter => parameter.ParameterType)
.SequenceEqual(parameterTypes));
private static bool IsHex(char value) =>
value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
}
@@ -416,6 +446,7 @@ internal sealed class InstalledK3dInteropFileLease
internal sealed record InstalledK3dInteropTypes(
Type EngineType,
Type EventHandlerType,
Type EventHandlerInterfaceType,
Type ScenePlayerType,
Type SceneType,
Type ObjectType,
@@ -468,6 +499,9 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo
[
"SetSceneEffectType",
"SetOutputChannelIndex",
"SetBackgroundTexture",
"SetBackgroundVideo",
"UseBackground",
"QueryVariables",
"GetObject",
"Unload"
@@ -476,7 +510,21 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo
private static readonly IReadOnlySet<string> ObjectMethods = new HashSet<string>(
[
"SetValue",
"SetVisible"
"SetVisible",
"SetFaceColor",
"SetPosition",
"SetPositionKey",
"SetScale",
"SetCropKey",
"SetCircleAngleKey",
"BeginPathPoint",
"ClearPathPoints",
"AddPathPoint",
"EndPathPoint",
"BeginPathShapePoint",
"ClearPathShapePoints",
"AddPathShapePoint",
"EndPathShapePoint"
], StringComparer.Ordinal);
public object? Invoke(object target, string method, params object?[] arguments)

View File

@@ -712,6 +712,7 @@ internal sealed class PgmCutsSequenceRunner
OutputChannel = null,
LayoutIndex = K3dComConstants.LegacyLayoutIndex,
SceneDirectory = request.SceneRoot,
TestSceneAllowlist = [FirstSceneCode, NextSceneCode],
TrustedLiveOutputEnabled = true,
QueueCapacity = 8,
ConnectTimeoutMilliseconds = 5_000,

View File

@@ -0,0 +1,349 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
internal enum K3dLifecycleCallbackKind
{
ScenePlayed,
CutOut,
StopAll
}
internal readonly record struct K3dLifecycleCallback(
K3dLifecycleCallbackKind Kind,
bool IsSuccess,
int? OutputChannelIndex,
int? LayerIndex);
public interface IK3dEventSink
{
void OnCallback(string methodName, object?[] arguments);
}
internal interface IK3dEventHandlerFactory
{
bool SupportsCallbacks { get; }
object Create(IK3dEventSink sink);
}
internal sealed class ActivatorK3dEventHandlerFactory(ILateBoundComActivator activator)
: IK3dEventHandlerFactory
{
private readonly ILateBoundComActivator _activator =
activator ?? throw new ArgumentNullException(nameof(activator));
public bool SupportsCallbacks => false;
public object Create(IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
return _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
}
}
internal sealed class InstalledK3dEventHandlerFactory : IK3dEventHandlerFactory
{
public bool SupportsCallbacks => true;
public object Create(IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
var interfaceType = InstalledK3dInteropMetadata.Resolve().EventHandlerInterfaceType;
return DynamicK3dEventHandlerBuilder.Create(interfaceType, sink);
}
}
internal sealed class K3dEventQueue : IK3dEventSink
{
private readonly ConcurrentQueue<K3dLifecycleCallback> _callbacks = new();
private readonly int _capacity;
private int _count;
private int _overflowed;
private int _helloObserved;
public K3dEventQueue(int capacity = 256)
{
if (capacity < 1)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_capacity = capacity;
}
public bool HelloObserved => Volatile.Read(ref _helloObserved) != 0;
public bool HasOverflowed => Volatile.Read(ref _overflowed) != 0;
public int PendingCount => Volatile.Read(ref _count);
public void OnCallback(string methodName, object?[] arguments)
{
// Nothing may escape across the native callback boundary.
try
{
if (string.Equals(methodName, "OnHello", StringComparison.Ordinal))
{
Volatile.Write(ref _helloObserved, 1);
return;
}
K3dLifecycleCallback callback;
if (string.Equals(methodName, "OnScenePlayed", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var playSuccess) &&
TryReadInt(arguments, 1, out var playChannel) &&
TryReadInt(arguments, 2, out var playLayer))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.ScenePlayed,
playSuccess != 0,
playChannel,
playLayer);
}
else if (string.Equals(methodName, "OnCutOut", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var cutSuccess) &&
TryReadInt(arguments, 1, out var cutChannel) &&
TryReadInt(arguments, 2, out var cutLayer))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.CutOut,
cutSuccess != 0,
cutChannel,
cutLayer);
}
else if (string.Equals(methodName, "OnStopAll", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var stopSuccess))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.StopAll,
stopSuccess != 0,
null,
null);
}
else
{
return;
}
var count = Interlocked.Increment(ref _count);
if (count > _capacity)
{
Interlocked.Decrement(ref _count);
Volatile.Write(ref _overflowed, 1);
return;
}
_callbacks.Enqueue(callback);
}
catch
{
Volatile.Write(ref _overflowed, 1);
}
}
public bool TryDequeue(out K3dLifecycleCallback callback)
{
if (!_callbacks.TryDequeue(out callback))
{
return false;
}
Interlocked.Decrement(ref _count);
return true;
}
private static bool TryReadInt(object?[] arguments, int index, out int value)
{
value = 0;
if ((uint)index >= (uint)arguments.Length || arguments[index] is null)
{
return false;
}
try
{
value = Convert.ToInt32(
arguments[index],
System.Globalization.CultureInfo.InvariantCulture);
return true;
}
catch
{
return false;
}
}
}
internal static class DynamicK3dEventHandlerBuilder
{
private static readonly ConcurrentDictionary<Type, ConstructorInfo> Constructors = new();
private static readonly MethodInfo SinkCallbackMethod = typeof(IK3dEventSink).GetMethod(
nameof(IK3dEventSink.OnCallback),
BindingFlags.Public | BindingFlags.Instance)
?? throw new MissingMethodException(nameof(IK3dEventSink.OnCallback));
private static readonly IReadOnlySet<string> ForwardedCallbacks = new HashSet<string>(
[
"OnHello",
"OnScenePlayed",
"OnCutOut",
"OnStopAll"
], StringComparer.Ordinal);
public static object Create(Type eventHandlerInterface, IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(eventHandlerInterface);
ArgumentNullException.ThrowIfNull(sink);
if (!eventHandlerInterface.IsInterface || !eventHandlerInterface.IsImport ||
eventHandlerInterface.GUID != Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832"))
{
throw new InvalidOperationException("The installed K3D callback interface is invalid.");
}
var constructor = Constructors.GetOrAdd(eventHandlerInterface, BuildConstructor);
return constructor.Invoke([sink]);
}
private static ConstructorInfo BuildConstructor(Type eventHandlerInterface)
{
var assembly = AssemblyBuilder.DefineDynamicAssembly(
new AssemblyName($"MBN.K3D.Callback.{Guid.NewGuid():N}"),
AssemblyBuilderAccess.RunAndCollect);
var module = assembly.DefineDynamicModule("Callbacks");
var type = module.DefineType(
$"K3dEventHandler_{Guid.NewGuid():N}",
TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class);
type.AddInterfaceImplementation(eventHandlerInterface);
var comVisible = typeof(ComVisibleAttribute).GetConstructor([typeof(bool)])!;
type.SetCustomAttribute(new CustomAttributeBuilder(comVisible, [true]));
var classInterface = typeof(ClassInterfaceAttribute).GetConstructor([typeof(ClassInterfaceType)])!;
type.SetCustomAttribute(new CustomAttributeBuilder(
classInterface,
[ClassInterfaceType.None]));
var sinkField = type.DefineField(
"_sink",
typeof(IK3dEventSink),
FieldAttributes.Private | FieldAttributes.InitOnly);
var constructor = type.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
[typeof(IK3dEventSink)]);
var constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!);
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
constructorIl.Emit(OpCodes.Stfld, sinkField);
constructorIl.Emit(OpCodes.Ret);
var interfaces = eventHandlerInterface.GetInterfaces().Append(eventHandlerInterface);
var methods = interfaces
.SelectMany(item => item.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
.Distinct()
.ToArray();
if (methods.Length == 0 || methods.Any(method => method.ReturnType != typeof(void)))
{
throw new InvalidOperationException("The installed K3D callback surface is unexpected.");
}
foreach (var method in methods)
{
ImplementMethod(type, sinkField, method);
}
var generated = type.CreateType()
?? throw new InvalidOperationException("The K3D callback adapter could not be generated.");
return generated.GetConstructor([typeof(IK3dEventSink)])
?? throw new MissingMethodException("The K3D callback adapter constructor is missing.");
}
private static void ImplementMethod(
TypeBuilder type,
FieldBuilder sinkField,
MethodInfo interfaceMethod)
{
var parameters = interfaceMethod.GetParameters();
var method = type.DefineMethod(
interfaceMethod.Name,
MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final |
MethodAttributes.HideBySig | MethodAttributes.NewSlot,
CallingConventions.Standard,
typeof(void),
parameters.Select(parameter => parameter.ParameterType).ToArray());
for (var index = 0; index < parameters.Length; index++)
{
method.DefineParameter(index + 1, parameters[index].Attributes, parameters[index].Name);
}
var il = method.GetILGenerator();
if (ForwardedCallbacks.Contains(interfaceMethod.Name))
{
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldfld, sinkField);
il.Emit(OpCodes.Ldstr, interfaceMethod.Name);
EmitInt(il, parameters.Length);
il.Emit(OpCodes.Newarr, typeof(object));
for (var index = 0; index < parameters.Length; index++)
{
il.Emit(OpCodes.Dup);
EmitInt(il, index);
EmitLoadArgument(il, index + 1);
var parameterType = parameters[index].ParameterType;
if (parameterType.IsByRef)
{
parameterType = parameterType.GetElementType()
?? throw new InvalidOperationException("The K3D callback parameter is invalid.");
il.Emit(OpCodes.Ldobj, parameterType);
}
if (parameterType.IsValueType)
{
il.Emit(OpCodes.Box, parameterType);
}
il.Emit(OpCodes.Stelem_Ref);
}
il.Emit(OpCodes.Callvirt, SinkCallbackMethod);
}
il.Emit(OpCodes.Ret);
type.DefineMethodOverride(method, interfaceMethod);
}
private static void EmitLoadArgument(ILGenerator il, int index)
{
switch (index)
{
case 0: il.Emit(OpCodes.Ldarg_0); break;
case 1: il.Emit(OpCodes.Ldarg_1); break;
case 2: il.Emit(OpCodes.Ldarg_2); break;
case 3: il.Emit(OpCodes.Ldarg_3); break;
default: il.Emit(OpCodes.Ldarg, index); break;
}
}
private static void EmitInt(ILGenerator il, int value)
{
switch (value)
{
case 0: il.Emit(OpCodes.Ldc_I4_0); break;
case 1: il.Emit(OpCodes.Ldc_I4_1); break;
case 2: il.Emit(OpCodes.Ldc_I4_2); break;
case 3: il.Emit(OpCodes.Ldc_I4_3); break;
case 4: il.Emit(OpCodes.Ldc_I4_4); break;
case 5: il.Emit(OpCodes.Ldc_I4_5); break;
case 6: il.Emit(OpCodes.Ldc_I4_6); break;
case 7: il.Emit(OpCodes.Ldc_I4_7); break;
case 8: il.Emit(OpCodes.Ldc_I4_8); break;
default: il.Emit(OpCodes.Ldc_I4, value); break;
}
}
}

View File

@@ -14,6 +14,14 @@ internal interface IK3dSession : IDisposable
PlayoutKtapConnectState LastKtapConnectState { get; }
bool SupportsLifecycleCallbacks { get; }
bool? KtapHelloObserved { get; }
bool HasPendingLifecycleCallbacks { get; }
bool HasPendingPlayCallbacks { get; }
bool TryPreventKtapConnect();
void Connect(ValidatedPlayoutOptions options);
@@ -22,13 +30,22 @@ internal interface IK3dSession : IDisposable
void Prepare(PlayoutCue cue, int layoutIndex);
void UpdateOnAir(PlayoutCue cue, int layoutIndex);
void Play(int layoutIndex);
void TakeOut(int layoutIndex, PlayoutTakeOutScope scope);
K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex);
void Abandon();
}
internal readonly record struct K3dCallbackDrainResult(
int CallbackCount,
int UnloadedSceneCount,
bool HelloObserved);
internal interface IK3dGuardedConnectSession : IK3dSession
{
void ConnectGuarded(
@@ -51,31 +68,46 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
{
private readonly ILateBoundComActivator _activator;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
public DynamicK3dSessionFactory()
: this(
new InstalledK3dInteropComActivator(),
new InstalledK3dInteropMethodInvoker())
new InstalledK3dInteropMethodInvoker(),
new InstalledK3dEventHandlerFactory())
{
}
internal DynamicK3dSessionFactory(ILateBoundComActivator activator)
: this(activator, new InstalledK3dInteropMethodInvoker())
: this(
activator,
new InstalledK3dInteropMethodInvoker(),
new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker)
: this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker,
IK3dEventHandlerFactory eventHandlerFactory)
{
_activator = activator;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
}
public IK3dSession Create() => new DynamicK3dSession(
_activator,
new RuntimeComObjectReleaser(),
_invoker);
_invoker,
_eventHandlerFactory);
}
internal interface ILateBoundComActivator
@@ -120,23 +152,30 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
private readonly ILateBoundComActivator _activator;
private readonly ILateBoundComObjectReleaser _releaser;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
private readonly K3dEventQueue _eventQueue = new();
private object? _engine;
private object? _eventHandler;
private object? _player;
private object? _preparedScene;
private object? _currentScene;
private readonly List<object> _retiredScenes = [];
private readonly List<object> _pendingTakeOutScenes = [];
private int? _outputChannel;
private int? _ownerThreadId;
private int _lastKtapConnectState;
private int _ktapDispatchGate;
private bool _disposed;
private int _pendingPlayCallbacks;
private int _pendingCutOutCallbacks;
private int _pendingStopAllCallbacks;
public DynamicK3dSession(ILateBoundComActivator activator)
: this(
activator,
new RuntimeComObjectReleaser(),
new InstalledK3dInteropMethodInvoker())
new InstalledK3dInteropMethodInvoker(),
new ActivatorK3dEventHandlerFactory(activator))
{
}
@@ -151,10 +190,24 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
ILateBoundComActivator activator,
ILateBoundComObjectReleaser releaser,
ILateBoundComMethodInvoker invoker)
: this(
activator,
releaser,
invoker,
new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSession(
ILateBoundComActivator activator,
ILateBoundComObjectReleaser releaser,
ILateBoundComMethodInvoker invoker,
IK3dEventHandlerFactory eventHandlerFactory)
{
_activator = activator;
_releaser = releaser;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
}
public bool IsConnected => _engine is not null && _player is not null;
@@ -162,6 +215,21 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
public PlayoutKtapConnectState LastKtapConnectState =>
(PlayoutKtapConnectState)Volatile.Read(ref _lastKtapConnectState);
public bool SupportsLifecycleCallbacks => _eventHandlerFactory.SupportsCallbacks;
public bool? KtapHelloObserved => SupportsLifecycleCallbacks
? _eventQueue.HelloObserved
: null;
public bool HasPendingLifecycleCallbacks => SupportsLifecycleCallbacks &&
(_pendingPlayCallbacks != 0 ||
_pendingCutOutCallbacks != 0 ||
_pendingStopAllCallbacks != 0 ||
_eventQueue.PendingCount != 0);
public bool HasPendingPlayCallbacks =>
SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0;
public bool TryPreventKtapConnect()
{
var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0);
@@ -210,7 +278,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
var ktapConnectAttempted = false;
try
{
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
_eventHandler = _eventHandlerFactory.Create(_eventQueue);
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
if (finalSafetyCheck is not null && !finalSafetyCheck())
{
@@ -358,6 +426,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
K3dComConstants.SceneChangeEffectFade,
cue.FadeDuration);
foreach (var mutation in cue.Mutations ?? [])
{
if (IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplySceneSetupMutation(nextScene, mutation);
}
}
Invoke(engine, "BeginTransaction");
transactionStarted = true;
foreach (var field in cue.Fields ?? [])
@@ -365,6 +441,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
ApplyField(nextScene, field);
}
foreach (var mutation in cue.Mutations ?? [])
{
if (!IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplyTransactionMutation(nextScene, mutation);
}
}
Invoke(nextScene, "QueryVariables");
if (outputChannel.HasValue)
{
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
@@ -375,7 +461,6 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
transactionStarted = false;
Invoke(nextScene, "QueryVariables");
Invoke(player, "Prepare", layoutIndex, nextScene);
var replacedPreparedScene = _preparedScene;
@@ -409,7 +494,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
{
EnsureThread();
EnsureConnected();
Invoke(_player!, "Play", layoutIndex);
InvokeTrackedPlay(layoutIndex);
var preparedScene = _preparedScene;
if (preparedScene is not null)
@@ -426,26 +511,252 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
private void InvokeTrackedPlay(int layoutIndex)
{
if (SupportsLifecycleCallbacks)
{
_pendingPlayCallbacks++;
}
try
{
Invoke(_player!, "Play", layoutIndex);
}
catch
{
if (SupportsLifecycleCallbacks)
{
_pendingPlayCallbacks--;
}
throw;
}
}
public void UpdateOnAir(PlayoutCue cue, int layoutIndex)
{
ArgumentNullException.ThrowIfNull(cue);
EnsureThread();
EnsureConnected();
var engine = _engine!;
var player = _player!;
var outputChannel = _outputChannel;
object? playingScene = null;
var transactionStarted = false;
try
{
// MainForm.timer1_Tick replays the current layout before entering
// Show_PlayList(idx: 1), then prepares and plays the mutated scene again.
InvokeTrackedPlay(layoutIndex);
playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex);
Invoke(engine, "BeginTransaction");
transactionStarted = true;
foreach (var field in cue.Fields ?? [])
{
ApplyField(playingScene, field);
}
foreach (var mutation in cue.Mutations ?? [])
{
// The legacy idx == 1 path updates the playing scene in place. It does not
// reapply scene-level background setup or the scene transition effect.
if (!IsSceneLevelMutation(mutation))
{
ApplyMutation(playingScene, mutation);
}
}
Invoke(playingScene, "QueryVariables");
if (outputChannel.HasValue)
{
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
}
else
{
Invoke(engine, "EndTransaction");
}
transactionStarted = false;
Invoke(player, "Prepare", layoutIndex, playingScene);
InvokeTrackedPlay(layoutIndex);
// GetPlayingScene can return the same RCW or another RCW for the same COM
// scene. An in-place page update must never retire/unload the active scene.
if (_currentScene is null)
{
_currentScene = playingScene;
playingScene = null;
}
else if (ReferenceEquals(_currentScene, playingScene))
{
playingScene = null;
}
}
catch
{
if (transactionStarted)
{
try
{
Invoke(engine, "RollbackTransaction");
}
catch
{
// Preserve the original SDK failure. The engine quarantines this session.
}
}
throw;
}
finally
{
ReleaseComObject(playingScene);
}
}
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
{
EnsureThread();
EnsureConnected();
if (scope == PlayoutTakeOutScope.All)
if (SupportsLifecycleCallbacks)
{
Invoke(_player!, "StopAll");
if (scope == PlayoutTakeOutScope.All)
{
_pendingStopAllCallbacks++;
}
else
{
_pendingCutOutCallbacks++;
}
}
try
{
if (scope == PlayoutTakeOutScope.All)
{
Invoke(_player!, "StopAll");
}
else
{
Invoke(_player!, "CutOut", layoutIndex);
}
}
catch
{
if (SupportsLifecycleCallbacks)
{
if (scope == PlayoutTakeOutScope.All)
{
_pendingStopAllCallbacks--;
}
else
{
_pendingCutOutCallbacks--;
}
}
throw;
}
if (SupportsLifecycleCallbacks)
{
MoveAllScenesToPendingTakeOut();
}
else
{
Invoke(_player!, "CutOut", layoutIndex);
var currentScene = _currentScene;
_currentScene = null;
if (currentScene is not null)
{
RetainRetiredScene(currentScene);
}
}
var currentScene = _currentScene;
_currentScene = null;
if (currentScene is not null)
}
public K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex)
{
EnsureThread();
EnsureConnected();
if (!SupportsLifecycleCallbacks)
{
RetainRetiredScene(currentScene);
return new K3dCallbackDrainResult(0, 0, false);
}
if (_eventQueue.HasOverflowed)
{
throw new InvalidOperationException("The K3D callback queue overflowed.");
}
var callbackCount = 0;
var unloadedSceneCount = 0;
while (_eventQueue.TryDequeue(out var callback))
{
callbackCount++;
switch (callback.Kind)
{
case K3dLifecycleCallbackKind.ScenePlayed:
if (_pendingPlayCallbacks == 0 ||
!MatchesOutput(callback, layoutIndex))
{
continue;
}
_pendingPlayCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D play callback reported failure.");
}
unloadedSceneCount += UnloadAndRelease(_retiredScenes);
break;
case K3dLifecycleCallbackKind.CutOut:
if (_pendingCutOutCallbacks == 0 ||
!MatchesOutput(callback, layoutIndex))
{
continue;
}
_pendingCutOutCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D cut-out callback reported failure.");
}
// CutOut terminates the selected layer. Any Play completion for that
// layer which has not arrived yet can no longer be required for safe
// lifetime accounting.
_pendingPlayCallbacks = 0;
unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes);
break;
case K3dLifecycleCallbackKind.StopAll:
if (_pendingStopAllCallbacks == 0)
{
continue;
}
_pendingStopAllCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D stop-all callback reported failure.");
}
// StopAll terminates every layer owned by this player. A stopped Play
// is not guaranteed to produce a later OnScenePlayed callback.
_pendingPlayCallbacks = 0;
unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes);
break;
default:
throw new InvalidOperationException("The K3D callback is not allowlisted.");
}
}
return new K3dCallbackDrainResult(
callbackCount,
unloadedSceneCount,
_eventQueue.HelloObserved);
}
public void Abandon()
@@ -513,6 +824,180 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
}
private void ApplySceneSetupMutation(object scene, PlayoutMutation mutation)
{
switch (mutation)
{
case PlayoutUseBackground background:
Invoke(scene, "UseBackground", background.IsEnabled ? 1 : 0);
break;
case PlayoutSetBackgroundTexture texture:
Invoke(scene, "SetBackgroundTexture", texture.AssetPath);
break;
case PlayoutSetBackgroundVideo video:
Invoke(
scene,
"SetBackgroundVideo",
video.AssetPath,
video.LoopCount,
video.LoopInfinite ? 1 : 0);
break;
default:
throw new InvalidOperationException("The scene setup mutation is not allowlisted.");
}
}
private void ApplyTransactionMutation(object scene, PlayoutMutation mutation)
{
if (IsSceneLevelMutation(mutation))
{
ApplySceneSetupMutation(scene, mutation);
return;
}
ApplyMutation(scene, mutation);
}
private static bool IsSceneLevelMutation(PlayoutMutation mutation) => mutation is
PlayoutUseBackground or PlayoutSetBackgroundTexture or PlayoutSetBackgroundVideo;
private static bool IsBeforeTransactionSceneSetupMutation(PlayoutMutation mutation) =>
mutation switch
{
PlayoutUseBackground background =>
background.Timing == PlayoutMutationTiming.BeforeTransaction,
PlayoutSetBackgroundTexture texture =>
texture.Timing == PlayoutMutationTiming.BeforeTransaction,
PlayoutSetBackgroundVideo video =>
video.Timing == PlayoutMutationTiming.BeforeTransaction,
_ => false
};
private void ApplyMutation(object scene, PlayoutMutation mutation)
{
var objectName = mutation switch
{
PlayoutSetValue value => value.ObjectName,
PlayoutSetAssetValue value => value.ObjectName,
PlayoutSetVisible visible => visible.ObjectName,
PlayoutSetFaceColor color => color.ObjectName,
PlayoutSetPosition position => position.ObjectName,
PlayoutSetPositionKey positionKey => positionKey.ObjectName,
PlayoutSetScale scale => scale.ObjectName,
PlayoutSetCropKey crop => crop.ObjectName,
PlayoutSetCircleAngleKey angle => angle.ObjectName,
PlayoutSetPathPoints path => path.ObjectName,
PlayoutSetPathShapePoints shape => shape.ObjectName,
_ => throw new InvalidOperationException("The scene mutation is not allowlisted.")
};
var sceneObject = InvokeRequired(scene, "GetObject", objectName);
try
{
switch (mutation)
{
case PlayoutSetValue value:
Invoke(sceneObject, "SetValue", value.Value);
break;
case PlayoutSetAssetValue asset:
Invoke(sceneObject, "SetValue", asset.AssetPath);
break;
case PlayoutSetVisible visible:
Invoke(sceneObject, "SetVisible", visible.IsVisible ? 1 : 0);
break;
case PlayoutSetFaceColor color:
Invoke(
sceneObject,
"SetFaceColor",
color.Red,
color.Green,
color.Blue,
color.Alpha);
break;
case PlayoutSetPosition position:
Invoke(
sceneObject,
"SetPosition",
position.X,
position.Y,
position.Z,
(int)position.Components);
break;
case PlayoutSetPositionKey positionKey:
Invoke(
sceneObject,
"SetPositionKey",
positionKey.KeyIndex,
positionKey.X,
positionKey.Y,
positionKey.Z,
(int)positionKey.Components);
break;
case PlayoutSetScale scale:
Invoke(
sceneObject,
"SetScale",
scale.X,
scale.Y,
scale.Z,
(int)scale.Components);
break;
case PlayoutSetCropKey crop:
Invoke(
sceneObject,
"SetCropKey",
crop.KeyIndex,
crop.Left,
crop.Top,
crop.Right,
crop.Bottom,
(int)crop.Edges);
break;
case PlayoutSetCircleAngleKey angle:
Invoke(
sceneObject,
"SetCircleAngleKey",
angle.KeyIndex,
angle.Start,
angle.End,
(int)angle.Components);
break;
case PlayoutSetPathPoints path:
ReplacePathPoints(sceneObject, path.Points, shape: false);
break;
case PlayoutSetPathShapePoints shape:
ReplacePathPoints(sceneObject, shape.Points, shape: true);
break;
default:
throw new InvalidOperationException("The scene mutation is not allowlisted.");
}
}
finally
{
ReleaseComObject(sceneObject);
}
}
private void ReplacePathPoints(
object sceneObject,
IReadOnlyList<PlayoutPoint> points,
bool shape)
{
var beginMethod = shape ? "BeginPathShapePoint" : "BeginPathPoint";
var clearMethod = shape ? "ClearPathShapePoints" : "ClearPathPoints";
var addMethod = shape ? "AddPathShapePoint" : "AddPathPoint";
var endMethod = shape ? "EndPathShapePoint" : "EndPathPoint";
Invoke(sceneObject, beginMethod);
Invoke(sceneObject, clearMethod);
foreach (var point in points)
{
Invoke(sceneObject, addMethod, point.X, point.Y, point.Z);
}
Invoke(sceneObject, endMethod);
}
private void EnsureThread()
{
var currentThreadId = Environment.CurrentManagedThreadId;
@@ -555,6 +1040,15 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
_retiredScenes.Clear();
foreach (var pendingTakeOutScene in _pendingTakeOutScenes)
{
AddDistinctScene(scenes, pendingTakeOutScene);
}
_pendingTakeOutScenes.Clear();
_pendingPlayCallbacks = 0;
_pendingCutOutCallbacks = 0;
_pendingStopAllCallbacks = 0;
var player = _player;
_player = null;
_outputChannel = null;
@@ -584,6 +1078,42 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
}
private void MoveAllScenesToPendingTakeOut()
{
AddDistinctScene(_pendingTakeOutScenes, _preparedScene);
_preparedScene = null;
AddDistinctScene(_pendingTakeOutScenes, _currentScene);
_currentScene = null;
foreach (var retiredScene in _retiredScenes)
{
AddDistinctScene(_pendingTakeOutScenes, retiredScene);
}
_retiredScenes.Clear();
}
private int UnloadAndRelease(List<object> scenes)
{
var unloaded = 0;
while (scenes.Count > 0)
{
var scene = scenes[0];
Invoke(scene, "Unload");
scenes.RemoveAt(0);
ReleaseComObject(scene);
unloaded++;
}
return unloaded;
}
private bool MatchesOutput(K3dLifecycleCallback callback, int layoutIndex)
{
var expectedChannel = _outputChannel ?? 0;
return callback.OutputChannelIndex == expectedChannel &&
callback.LayerIndex == layoutIndex;
}
private static void AddDistinctScene(List<object> scenes, object? scene)
{
if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene)))

View File

@@ -37,6 +37,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
private int _ktapHelloObservedState;
private bool _connectionRequested;
private volatile bool _outcomeUnknown;
private volatile bool _quarantineIncomplete;
@@ -118,6 +119,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
token => NextCoreAsync(cue, token));
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cue);
return SerializedAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken,
token => UpdateOnAirCoreAsync(cue, token));
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
@@ -190,7 +202,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined)
{
if (_onAirSceneName is not null || _outcomeUnknown)
if (_session.HasPendingLifecycleCallbacks &&
!await ProcessLifecycleCallbacksAsync(CancellationToken.None).ConfigureAwait(false))
{
_outcomeUnknown = true;
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
_outcomeUnknown = true;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
}
else if (_onAirSceneName is not null || _outcomeUnknown)
{
_outcomeUnknown = true;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
@@ -307,6 +330,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
: PlayoutConnectionState.Disconnected;
}
if (_session is not null &&
!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
_timeProvider.GetUtcNow() < _nextReconnectAt)
@@ -702,6 +732,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
_connectionState = PlayoutConnectionState.Connected;
const string pendingMessage =
"Tornado 장면 완료 이벤트를 기다리는 중이므로 연결을 해제하지 않았습니다.";
PublishStatus(pendingMessage);
return Result(
PlayoutOperation.Disconnect,
PlayoutResultCode.Unavailable,
pendingMessage);
}
}
_connectionState = PlayoutConnectionState.Disconnecting;
PublishStatus("Tornado 송출 연결을 해제하는 중입니다.");
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
@@ -749,9 +799,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message));
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."));
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다."));
}
return PrepareActualAsync(resolved, cancellationToken);
@@ -810,9 +861,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(_preparedCue))
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken)
@@ -879,9 +931,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message);
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken)
@@ -914,6 +967,108 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return result;
}
private async Task<PlayoutResult> UpdateOnAirCoreAsync(
PlayoutCue cue,
CancellationToken cancellationToken)
{
if (_onAirSceneName is null)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh requires a scene that is already on air.");
}
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Playout is disabled.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
if (!IsCurrentOnAirCue(cue))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh can update only the scene currently on air.");
}
_preparedCue = null;
var message = DryRunOperationMessage("Same-scene refresh was validated.");
PublishStatus(message);
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Success,
message);
}
if (!CanTakeIn())
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Live playout authorization is required.");
}
PlayoutCue resolved;
try
{
resolved = _options.ResolveCue(cue);
}
catch (PlayoutRequestException exception)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
exception.Message);
}
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"The scene is not approved for output.");
}
if (!IsCurrentOnAirCue(resolved))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh can update only the scene currently on air.");
}
var availability = await EnsureCommandAvailableAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken).ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var result = await RunSessionOperationAsync(
PlayoutOperation.UpdateOnAir,
(session, dispatchLatch) => ExecuteSdkDispatch(
dispatchLatch,
() => session.UpdateOnAir(resolved, _options.LayoutIndex)),
partialOutcomeUnknown: true,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAirSceneName = SafeSceneName(resolved);
_preparedCue = null;
PublishStatus("The on-air scene page was updated.");
}
return result;
}
private async Task<PlayoutResult> TakeOutCoreAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken)
@@ -960,7 +1115,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("장면 송출이 종료되었습니다.");
PublishStatus(_session?.HasPendingLifecycleCallbacks == true
? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다."
: "장면 송출이 종료되었습니다.");
}
return result;
@@ -1042,9 +1199,76 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다.");
}
if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
if (RequiresCompletedPlay(operation) &&
_session?.HasPendingPlayCallbacks == true)
{
const string pendingPlayMessage =
"The previous SDK Play completion callback is still pending. " +
"Only TAKE OUT is allowed until Tornado confirms the result.";
PublishStatus(pendingPlayMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
pendingPlayMessage);
}
return null;
}
private static bool RequiresCompletedPlay(PlayoutOperation operation) => operation is
PlayoutOperation.Prepare or
PlayoutOperation.TakeIn or
PlayoutOperation.Next or
PlayoutOperation.UpdateOnAir;
private async Task<bool> ProcessLifecycleCallbacksAsync(
CancellationToken cancellationToken)
{
var session = _session;
if (session is null || !session.SupportsLifecycleCallbacks)
{
CaptureKtapEvidence(session);
return true;
}
if (_dispatcher is null || _dispatcher.IsQuarantined)
{
return false;
}
try
{
await _dispatcher.InvokeAsync(
() => session.ProcessPendingCallbacks(_options.LayoutIndex),
_options.OperationTimeout,
CancellationToken.None,
_ => AbandonSessionInline(session),
() => AbandonSessionInline(session)).ConfigureAwait(false);
CaptureKtapEvidence(session);
return true;
}
catch
{
_session = null;
_connectedProcessSnapshot = null;
if (_dispatcher.IsQuarantined)
{
_quarantineIncomplete = true;
}
else if (!await AbandonSessionOnStaAsync(session).ConfigureAwait(false))
{
_quarantineIncomplete = true;
}
return false;
}
}
private async Task<PlayoutResult> RunSessionOperationAsync(
PlayoutOperation operation,
Action<IK3dSession, SdkDispatchLatch> callback,
@@ -1177,6 +1401,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
}
var session = _session;
if (session?.HasPendingLifecycleCallbacks == true)
{
// A late OnScenePlayed/OnCutOut/OnStopAll callback still owns scene lifetime.
// Never issue Disconnect while that result is unresolved.
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return false;
}
_session = null;
_connectedProcessSnapshot = null;
if (session is null)
@@ -1599,7 +1831,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = null
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch
{
1 => false,
2 => true,
_ => null
}
};
}
@@ -1615,6 +1852,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
Volatile.Write(ref _lastKtapConnectState, (int)state);
}
if (session.SupportsLifecycleCallbacks)
{
Volatile.Write(
ref _ktapHelloObservedState,
session.KtapHelloObserved == true ? 2 : 1);
}
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
@@ -1660,6 +1904,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return value;
}
private bool IsCurrentOnAirCue(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) &&
string.Equals(sceneName, _onAirSceneName, StringComparison.OrdinalIgnoreCase);
}
private string CurrentMessage() => _connectionState switch
{
PlayoutConnectionState.Connected =>
@@ -1704,6 +1955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private static string SuccessMessage(PlayoutOperation operation) => operation switch
{
PlayoutOperation.UpdateOnAir => "The on-air scene page was updated.",
PlayoutOperation.Prepare => "장면이 준비되었습니다.",
PlayoutOperation.TakeIn => "장면이 송출되었습니다.",
PlayoutOperation.Next => "다음 장면이 송출되었습니다.",