SeibroBondSyncTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. using Application.Helpers;
  2. using Domain.Entities.Stocks;
  3. using Domain.Entities.Stocks.ValueObject;
  4. using Infrastructure.StockData;
  5. using Microsoft.EntityFrameworkCore;
  6. namespace Application.Tests;
  7. /// <summary>
  8. /// SeibroBondSyncService 의 DB 반영 로직(SeibroBondImport) + rolling 대상 선정 통합 검증 —
  9. /// HTTP 는 파서 입력을 fixture/합성 XML 로 대체(SeibroCorpActionSyncTests 방식 미러링). 실제 SQL Server(LocalDB).
  10. /// 발행 upsert / 마스터 rolling(KRX BondDailyTrade ISIN 합집합 대상) / 이자 / 조기상환 window / 단기발행 스탬핑 / CD·CP·ESTB 매핑(SECN_TPCD 분기) / Bond 예산 상한.
  11. /// </summary>
  12. [TestClass]
  13. public sealed class SeibroBondSyncTests
  14. {
  15. [TestMethod]
  16. public async Task UpsertBondIssuances_FromSampleXml_ThenIdempotent()
  17. {
  18. // 기대 행위: getBondIssuInfo(1행) upsert — UQ Isin. 재실행 무변경.
  19. var rows = SeibroBondIssuParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondIssuInfo.xml")));
  20. using (var db = TestDb.Create())
  21. {
  22. var (inserted, _) = await SeibroBondImport.UpsertBondIssuancesAsync(db, rows, CancellationToken.None);
  23. Assert.AreEqual(1, inserted);
  24. }
  25. using (var db = TestDb.Create())
  26. {
  27. var (inserted, updated) = await SeibroBondImport.UpsertBondIssuancesAsync(db, rows, CancellationToken.None);
  28. Assert.AreEqual(0, inserted, "재실행 idempotent");
  29. Assert.AreEqual(0, updated, "무변경 no-op");
  30. var saved = await db.BondIssuance.AsNoTracking().Where(c => c.Isin == "KR6268761881").SingleAsync();
  31. Assert.AreEqual(new DateOnly(2018, 8, 3), saved.IssuDt);
  32. Assert.AreEqual(29998, saved.IssucoCustno);
  33. }
  34. }
  35. [TestMethod]
  36. public async Task UpsertBondMaster_FromSampleXml_StampsIsin_AndCreditGrades()
  37. {
  38. // 기대 행위: getBondStatInfo — 응답에 ISIN 없어 요청 ISIN 스탬핑, PK Isin upsert, 신용등급 4사 저장.
  39. var rows = SeibroBondStatParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondStatInfo.xml")));
  40. const string isin = "KR6268761881";
  41. using (var db = TestDb.Create())
  42. {
  43. var ok = await SeibroBondImport.UpsertBondMasterAsync(db, isin, rows, CancellationToken.None);
  44. Assert.IsTrue(ok);
  45. }
  46. using (var db = TestDb.Create())
  47. {
  48. var saved = await db.BondMaster.AsNoTracking().Where(c => c.Isin == isin).SingleAsync();
  49. Assert.AreEqual("SK해운 61", saved.KorSecnNm);
  50. Assert.AreEqual(new DateOnly(2020, 2, 3), saved.XpirDt, "만기");
  51. Assert.AreEqual(4.485m, saved.CouponRate, "표면이자율");
  52. Assert.AreEqual("2", saved.GrtyTpcd, "무보증");
  53. Assert.AreEqual("133", saved.KisValatGrdCd);
  54. Assert.AreEqual("133", saved.KrValatGrdCd);
  55. Assert.IsNull(saved.NiceValatGrdCd, "빈칸 → null");
  56. }
  57. }
  58. [TestMethod]
  59. public async Task BondMasterTarget_UnionsBondIssuanceAndKrxBondDailyTrade()
  60. {
  61. // 기대 행위(rolling 대상 선정): BondMaster 대상 = BondIssuance.Isin ∪ KRX BondDailyTrade.Code 중복 제거.
  62. var issuedOnly = $"KRA{Guid.NewGuid().ToString("N")[..9]}";
  63. var krxOnly = $"KRB{Guid.NewGuid().ToString("N")[..9]}";
  64. var both = $"KRC{Guid.NewGuid().ToString("N")[..9]}";
  65. using (var db = TestDb.Create())
  66. {
  67. await db.BondIssuance.AddAsync(BondIssuance.Create(new DateOnly(2018, 8, 3), 1, "발행사", issuedOnly, "발행전용"), CancellationToken.None);
  68. await db.BondIssuance.AddAsync(BondIssuance.Create(new DateOnly(2018, 8, 3), 1, "발행사", both, "양쪽"), CancellationToken.None);
  69. await db.BondDailyTrade.AddAsync(NewTrade(krxOnly), CancellationToken.None);
  70. await db.BondDailyTrade.AddAsync(NewTrade(both), CancellationToken.None);
  71. await db.SaveChangesAsync(CancellationToken.None);
  72. }
  73. using (var db = TestDb.Create())
  74. {
  75. var issued = await db.BondIssuance.AsNoTracking().Select(c => c.Isin).Distinct().ToListAsync();
  76. var krx = await db.BondDailyTrade.AsNoTracking().Select(c => c.Code).Distinct().ToListAsync();
  77. var universe = issued.Union(krx, StringComparer.Ordinal).ToHashSet();
  78. Assert.IsTrue(universe.Contains(issuedOnly), "발행만");
  79. Assert.IsTrue(universe.Contains(krxOnly), "KRX만");
  80. Assert.IsTrue(universe.Contains(both), "양쪽 포함");
  81. Assert.AreEqual(1, universe.Count(c => c == both), "중복 제거로 both 는 1회");
  82. }
  83. }
  84. [TestMethod]
  85. public async Task UpsertInterestPayment_SyntheticXml_StampsIsin_SnapshotUpdate()
  86. {
  87. // 빈 fixture 대응 — 합성 XML 로 PK Isin 스냅샷 upsert + 재실행 갱신 검증.
  88. const string xml = """
  89. <vector result="1">
  90. <data vectorkey="0" type="Document"><result>
  91. <COUPON_RATE value="4.485"/><INT_PAY_WAY_TPCD value="1"/><INT_PAY_CYCLE_TERMS value="3"/>
  92. <INT_PAY_CYCLE_TPCD value="23"/><INT_PAY_TIMS_TPCD value="01"/>
  93. <BEFORE_DATE value="20180803"/><AFTER_DATE value="20181103"/>
  94. </result></data>
  95. </vector>
  96. """;
  97. var rows = SeibroIntPayParser.Parse(SeibroXml.Parse(xml));
  98. const string isin = "KR6268761881";
  99. using (var db = TestDb.Create())
  100. {
  101. Assert.IsTrue(await SeibroBondImport.UpsertInterestPaymentAsync(db, isin, rows, CancellationToken.None));
  102. }
  103. using (var db = TestDb.Create())
  104. {
  105. Assert.IsTrue(await SeibroBondImport.UpsertInterestPaymentAsync(db, isin, rows, CancellationToken.None), "재수집 idempotent upsert");
  106. var saved = await db.BondInterestPayment.AsNoTracking().Where(c => c.Isin == isin).SingleAsync();
  107. Assert.AreEqual("23", saved.IntPayCycleTpcd, "개월 주기");
  108. Assert.AreEqual(new DateOnly(2018, 11, 3), saved.AfterDate);
  109. }
  110. }
  111. [TestMethod]
  112. public async Task ReplaceEarlyRedemptionWindow_FromSampleXml_ThenReconstructs()
  113. {
  114. // 기대 행위: getBondOptionXrcInfo — 조기상환일 window delete+insert (UQ Isin+ErlyRedDt). 재실행 창 재구성.
  115. var rows = SeibroBondOptionXrcParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getBondOptionXrcInfo.xml")));
  116. var day = new DateOnly(2018, 8, 24);
  117. using (var db = TestDb.Create())
  118. {
  119. var (inserted, deleted) = await SeibroBondImport.ReplaceEarlyRedemptionWindowAsync(db, day, day, rows, CancellationToken.None);
  120. Assert.AreEqual(1, inserted);
  121. Assert.AreEqual(0, deleted);
  122. }
  123. using (var db = TestDb.Create())
  124. {
  125. var (inserted, deleted) = await SeibroBondImport.ReplaceEarlyRedemptionWindowAsync(db, day, day, rows, CancellationToken.None);
  126. Assert.AreEqual(1, inserted, "창 재구성");
  127. Assert.AreEqual(1, deleted, "기존 창 삭제");
  128. var saved = await db.BondEarlyRedemption.AsNoTracking().Where(c => c.Isin == "KR6225693573" && c.ErlyRedDt == day).SingleAsync();
  129. Assert.AreEqual("9401", saved.OptionTpcd, "CALL");
  130. Assert.AreEqual(2000000000m, saved.ErlyRedamtVal);
  131. }
  132. }
  133. [TestMethod]
  134. public async Task UpsertShortTermIssuances_FromSampleXml_StampsSecnTpcdAndIssuDt()
  135. {
  136. // 기대 행위: getShortmIssuInfo(다수행) — 응답에 없는 SECN_TPCD·ISSU_DT 를 요청값 스탬핑 upsert (UQ Isin).
  137. var rows = SeibroShortmIssuParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getShortmIssuInfo.xml")));
  138. var issuDt = new DateOnly(2018, 8, 1);
  139. using (var db = TestDb.Create())
  140. {
  141. var (inserted, _) = await SeibroBondImport.UpsertShortTermIssuancesAsync(db, "12", issuDt, rows, CancellationToken.None);
  142. Assert.IsTrue(inserted > 1, "다수 종목 insert");
  143. }
  144. using (var db = TestDb.Create())
  145. {
  146. var (inserted, updated) = await SeibroBondImport.UpsertShortTermIssuancesAsync(db, "12", issuDt, rows, CancellationToken.None);
  147. Assert.AreEqual(0, inserted, "재실행 idempotent");
  148. Assert.AreEqual(0, updated);
  149. var saved = await db.ShortTermIssuance.AsNoTracking().Where(c => c.Isin == "KRZF00405288").SingleAsync();
  150. Assert.AreEqual("12", saved.SecnTpcd, "구분 스탬핑");
  151. Assert.AreEqual(issuDt, saved.IssuDt, "발행일 스탬핑");
  152. Assert.AreEqual(405, saved.IssucoCustno);
  153. }
  154. }
  155. [TestMethod]
  156. public async Task ShortTermMasterTarget_BranchesBySecnTpcd()
  157. {
  158. // 기대 행위(SECN_TPCD 분기): 13→CD, 12→CP, 14→ESTB 대상 분리. 각 마스터 매핑 검증.
  159. var cdIsin = $"KRD{Guid.NewGuid().ToString("N")[..9]}";
  160. var cpIsin = $"KRE{Guid.NewGuid().ToString("N")[..9]}";
  161. var estbIsin = $"KRF{Guid.NewGuid().ToString("N")[..9]}";
  162. var issuDt = new DateOnly(2018, 8, 1);
  163. using (var db = TestDb.Create())
  164. {
  165. await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(cdIsin, "CD종목", 1, "발행", "13", issuDt), CancellationToken.None);
  166. await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(cpIsin, "CP종목", 2, "발행", "12", issuDt), CancellationToken.None);
  167. await db.ShortTermIssuance.AddAsync(ShortTermIssuance.Create(estbIsin, "전단채", 3, "발행", "14", issuDt), CancellationToken.None);
  168. await db.SaveChangesAsync(CancellationToken.None);
  169. }
  170. using (var db = TestDb.Create())
  171. {
  172. var cdTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "13" && !db.CdMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync();
  173. var cpTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "12" && !db.CpMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync();
  174. var estbTargets = await db.ShortTermIssuance.AsNoTracking().Where(c => c.SecnTpcd == "14" && !db.EstbMaster.Any(m => m.Isin == c.Isin)).Select(c => c.Isin).ToListAsync();
  175. CollectionAssert.Contains(cdTargets, cdIsin);
  176. CollectionAssert.DoesNotContain(cdTargets, cpIsin, "CP 는 CD 대상 아님");
  177. CollectionAssert.Contains(cpTargets, cpIsin);
  178. CollectionAssert.Contains(estbTargets, estbIsin);
  179. // CP 마스터 매핑 (fixture)
  180. var cpRows = SeibroCPParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getCPInfo.xml")));
  181. Assert.IsTrue(await SeibroBondImport.UpsertCpMasterAsync(db, cpIsin, cpRows, CancellationToken.None));
  182. var cp = await db.CpMaster.AsNoTracking().Where(c => c.Isin == cpIsin).SingleAsync();
  183. Assert.AreEqual("112", cp.KisValatGrdCd);
  184. Assert.AreEqual("교보증권", cp.DiscnOrdNm);
  185. // ESTB 마스터 매핑 (fixture)
  186. var estbRows = SeibroESTBParser.Parse(SeibroXml.Parse(SeibroFixtures.Read("getESTBInfo.xml")));
  187. Assert.IsTrue(await SeibroBondImport.UpsertEstbMasterAsync(db, estbIsin, estbRows, CancellationToken.None));
  188. var estb = await db.EstbMaster.AsNoTracking().Where(c => c.Isin == estbIsin).SingleAsync();
  189. Assert.AreEqual("3", estb.EstbKacd, "PF AB");
  190. Assert.AreEqual("112", estb.NiceValatGrdCd);
  191. // CD 마스터 매핑 (합성 — 빈 fixture)
  192. const string cdXml = """
  193. <vector result="1"><data vectorkey="0" type="Document"><result>
  194. <ISSUCO_CUSTNO value="3061"/><FIRST_ISSU_AMT value="10000000000"/><DISCN_RATE value="1.85"/>
  195. <INT_PAY_MANN_TPCD value="1"/><ISSU_DT value="20180801"/><XPIR_DT value="20181031"/>
  196. </result></data></vector>
  197. """;
  198. var cdRows = SeibroCDParser.Parse(SeibroXml.Parse(cdXml));
  199. Assert.IsTrue(await SeibroBondImport.UpsertCdMasterAsync(db, cdIsin, cdRows, CancellationToken.None));
  200. var cd = await db.CdMaster.AsNoTracking().Where(c => c.Isin == cdIsin).SingleAsync();
  201. Assert.AreEqual(1.85m, cd.DiscnRate);
  202. Assert.AreEqual("1", cd.IntPayMannTpcd);
  203. }
  204. }
  205. [TestMethod]
  206. public void Quota_BondBudget_CapsConsumption()
  207. {
  208. // 기대 행위: 채권 예산 상한 — 소진 후 추가 소비 거부, 다른 카테고리 무영향.
  209. var quota = new SeibroQuota(() => new DateOnly(2026, 7, 7));
  210. Assert.IsTrue(quota.TryConsume(SeibroCategory.Bond, 8000, 8000), "예산 전량 소비");
  211. Assert.IsFalse(quota.TryConsume(SeibroCategory.Bond, 1, 8000), "채권 예산 소진");
  212. Assert.AreEqual(0, quota.Remaining(SeibroCategory.Bond, 8000));
  213. Assert.AreEqual(40000, quota.Remaining(SeibroCategory.Corp, 40000), "기업 예산 무영향");
  214. }
  215. private static BondDailyTrade NewTrade(string isin)
  216. {
  217. return BondDailyTrade.Create(BondMarket.General, isin, "채권매매", new DateOnly(2024, 1, 2), 10000m, 10000m, 10010m, 9990m, 5m, 3.5m, null, null, null, 100, 1000000);
  218. }
  219. }