KoreaEximRateParser.cs 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. using System.Globalization;
  2. using System.Text.Json;
  3. namespace Infrastructure.StockData;
  4. /// <summary>
  5. /// 한국수출입은행 OpenAPI AP02(대출금리) / AP03(국제금리) 응답 파서 — 순수 C# (System.Text.Json).
  6. /// 응답 골격: 최상위가 JSON 배열 [ { result, cur_nm(항목명), … 금리 수치 필드 }, ... ]. (AP01 환율과 같은 배열 shape, 필드만 다름)
  7. /// - result: 각 행의 조회 결과 코드 (1=성공). result != 1 인 행은 skip (2~4 = DATA/인증/제한 오류).
  8. /// - ItemName ← cur_nm (또는 대응 항목명 필드). 값은 전부 문자열, 천단위 콤마/빈값 가능.
  9. /// - Rate ← 항목의 금리 수치. koreaexim 금리 응답은 종류별로 수치 필드명이 달라, 알려진 후보 필드를 순서대로 탐색해
  10. /// 처음 파싱되는 숫자를 대표 금리로 삼는다 (deal_bas_r, ttb, tts, bkpr 등). 아무 것도 없으면 Rate=null (항목만 보존).
  11. /// koreaexim quirk: 주말/공휴일·오전 이전엔 빈 배열([]), 키/파라미터 오류 시 result 2~4 또는 빈 응답 → 예외 없이 빈 목록 반환.
  12. /// </summary>
  13. public static class KoreaEximRateParser
  14. {
  15. /// <summary>금리 1행 (searchdate 는 호출부가 채움). Rate 는 미고시 시 null.</summary>
  16. public sealed record RateRow(
  17. string ItemName,
  18. decimal? Rate
  19. );
  20. // 금리 수치가 담길 수 있는 후보 필드 (순서대로 첫 파싱 성공값을 채택)
  21. private static readonly string[] RateFields = ["deal_bas_r", "ttb", "tts", "bkpr", "kftc_deal_bas_r"];
  22. // 항목명이 담길 수 있는 후보 필드
  23. private static readonly string[] NameFields = ["cur_nm", "cur_unit", "item_nm"];
  24. public static IReadOnlyList<RateRow> Parse(string json)
  25. {
  26. var rows = new List<RateRow>();
  27. if (string.IsNullOrWhiteSpace(json))
  28. {
  29. return rows;
  30. }
  31. using var doc = JsonDocument.Parse(json);
  32. if (doc.RootElement.ValueKind != JsonValueKind.Array)
  33. {
  34. return rows;
  35. }
  36. foreach (var item in doc.RootElement.EnumerateArray())
  37. {
  38. if (item.ValueKind != JsonValueKind.Object)
  39. {
  40. continue;
  41. }
  42. if (GetInt(item, "result") != 1)
  43. {
  44. continue;
  45. }
  46. var itemName = FirstNonEmpty(item, NameFields);
  47. if (itemName.Length == 0)
  48. {
  49. continue;
  50. }
  51. rows.Add(new RateRow(itemName, FirstRate(item, RateFields)));
  52. }
  53. return rows;
  54. }
  55. private static string FirstNonEmpty(JsonElement element, string[] fields)
  56. {
  57. foreach (var field in fields)
  58. {
  59. var value = GetString(element, field);
  60. if (value.Length > 0)
  61. {
  62. return value;
  63. }
  64. }
  65. return string.Empty;
  66. }
  67. private static decimal? FirstRate(JsonElement element, string[] fields)
  68. {
  69. foreach (var field in fields)
  70. {
  71. var value = ParseNullableDecimal(GetString(element, field));
  72. if (value is not null)
  73. {
  74. return value;
  75. }
  76. }
  77. return null;
  78. }
  79. private static string GetString(JsonElement element, string name)
  80. {
  81. if (!element.TryGetProperty(name, out var value))
  82. {
  83. return string.Empty;
  84. }
  85. return value.ValueKind switch
  86. {
  87. JsonValueKind.String => value.GetString()?.Trim() ?? string.Empty,
  88. JsonValueKind.Number => value.GetRawText(),
  89. _ => string.Empty
  90. };
  91. }
  92. private static int GetInt(JsonElement element, string name)
  93. {
  94. if (!element.TryGetProperty(name, out var value))
  95. {
  96. return 0;
  97. }
  98. return value.ValueKind switch
  99. {
  100. JsonValueKind.Number => value.TryGetInt32(out var n) ? n : 0,
  101. JsonValueKind.String => int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var s) ? s : 0,
  102. _ => 0
  103. };
  104. }
  105. /// <summary>금리 문자열 → decimal (천단위 콤마 제거). "" 나 "-" 등 파싱 실패는 null.</summary>
  106. private static decimal? ParseNullableDecimal(string raw)
  107. {
  108. return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
  109. }
  110. }