feat: migrate legacy playout workflow and scenes
This commit is contained in:
418
src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs
Normal file
418
src/MBN_STOCK_WEBVIEW.Core/Data/DataQuerySpec.cs
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
331
src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs
Normal file
331
src/MBN_STOCK_WEBVIEW.Core/Playout/ScenePaging.cs
Normal 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);
|
||||
}
|
||||
703
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs
Normal file
703
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
1057
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs
Normal file
1057
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
298
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs
Normal file
298
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneCatalog.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() { } }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 폴더의 자산을 찾을 수 없습니다.");
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
349
src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs
Normal file
349
src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dEventHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 => "다음 장면이 송출되었습니다.",
|
||||
|
||||
Reference in New Issue
Block a user