KrxOptionsParserTests.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. using Domain.Entities.Stocks.ValueObject;
  2. using Infrastructure.StockData;
  3. namespace Application.Tests;
  4. [TestClass]
  5. public sealed class KrxOptionsParserTests
  6. {
  7. [TestMethod]
  8. public void ParseDaily_General_MapsFields_CallPut_StrikeFromName_OpenInterest()
  9. {
  10. // KRX OpenAPI(opt_bydd_trd) 실검증 샘플 — RGHT_TP_NM(CALL/PUT), IMP_VOLT(내재변동성), NXTDD_BAS_PRC(익일정산가), ACC_OPNINT_QTY(미결제약정)
  11. // 행사가는 필드 없이 ISU_NM "미니코스피 C 202501 232.5 (정규)" 에서 파싱 → 232.5
  12. const string json = """
  13. {"OutBlock_1":[
  14. {"BAS_DD":"20250102","PROD_NM":"미니코스피200 옵션","RGHT_TP_NM":"CALL","ISU_CD":"205W1232","ISU_NM":"미니코스피 C 202501 232.5 (정규)","TDD_CLSPRC":"","CMPPREVDD_PRC":"","TDD_OPNPRC":"","TDD_HGPRC":"","TDD_LWPRC":"","IMP_VOLT":"3.00","NXTDD_BAS_PRC":"85.40","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"3"},
  15. {"BAS_DD":"20250102","PROD_NM":"미니코스피200 옵션","RGHT_TP_NM":"PUT","ISU_CD":"305W1232","ISU_NM":"미니코스피 P 202501 232.5 (정규)","TDD_CLSPRC":"1.25","CMPPREVDD_PRC":"-0.10","TDD_OPNPRC":"1.30","TDD_HGPRC":"1.40","TDD_LWPRC":"1.20","IMP_VOLT":"12.34","NXTDD_BAS_PRC":"1.25","ACC_TRDVOL":"1,234","ACC_TRDVAL":"56,700,000","ACC_OPNINT_QTY":"7,890"}
  16. ]}
  17. """;
  18. var rows = KrxOptionsParser.ParseDaily(json, OptionsKind.General);
  19. Assert.AreEqual(2, rows.Count);
  20. var call = rows[0];
  21. Assert.AreEqual(OptionsKind.General, call.OptionsKind, "상품군은 호출 인자로 결정");
  22. Assert.AreEqual("205W1232", call.IsuCode, "8자리 영숫자 코드 원문 유지");
  23. Assert.AreEqual(OptionRightType.Call, call.RightType, "RGHT_TP_NM CALL → Call");
  24. Assert.AreEqual(232.5m, call.StrikePrice, "행사가는 ISU_NM 에서 파싱 (232.5)");
  25. Assert.AreEqual(0m, call.Close, "미체결 종가는 0");
  26. Assert.AreEqual(3.00m, call.ImpliedVolatility, "내재변동성 (IMP_VOLT)");
  27. Assert.AreEqual(85.40m, call.NextDayBasePrice, "익일정산가 (NXTDD_BAS_PRC)");
  28. Assert.AreEqual(3L, call.OpenInterest, "미결제약정 (ACC_OPNINT_QTY)");
  29. Assert.AreEqual("미니코스피200 옵션", call.ProductName);
  30. var put = rows[1];
  31. Assert.AreEqual(OptionRightType.Put, put.RightType, "RGHT_TP_NM PUT → Put");
  32. Assert.AreEqual(232.5m, put.StrikePrice);
  33. Assert.AreEqual(1.25m, put.Close);
  34. Assert.AreEqual(-0.10m, put.ChangeAmount, "음수 대비");
  35. Assert.AreEqual(1234L, put.Volume, "거래량 콤마 제거");
  36. Assert.AreEqual(56700000L, put.TradeValue, "거래대금 콤마 제거");
  37. Assert.AreEqual(7890L, put.OpenInterest, "미결제약정 콤마 제거");
  38. }
  39. [TestMethod]
  40. public void ParseDaily_StockOption_StrikeWithThousandsSeparator_IgnoresMultiplier()
  41. {
  42. // 주식옵션(유가) eqsop_bydd_trd 샘플 — 행사가에 천단위 콤마 + 뒤에 승수 "( 10)" 표기. "150,000" → 150000, 승수는 무시
  43. const string json = """
  44. {"OutBlock_1":[
  45. {"BAS_DD":"20250102","PROD_NM":"HD한국조선해양 옵션","RGHT_TP_NM":"CALL","ISU_CD":"239W1041","ISU_NM":"HD한국조선 C 202501 150,000( 10)","TDD_CLSPRC":"","CMPPREVDD_PRC":"","TDD_OPNPRC":"","TDD_HGPRC":"","TDD_LWPRC":"","IMP_VOLT":"38.30","NXTDD_BAS_PRC":"81000.00","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"0"}
  46. ]}
  47. """;
  48. var rows = KrxOptionsParser.ParseDaily(json, OptionsKind.StockKospi);
  49. Assert.AreEqual(1, rows.Count);
  50. var opt = rows[0];
  51. Assert.AreEqual(OptionsKind.StockKospi, opt.OptionsKind);
  52. Assert.AreEqual(OptionRightType.Call, opt.RightType);
  53. Assert.AreEqual(150000m, opt.StrikePrice, "행사가 콤마 제거, 승수 (10) 무시 → 150000");
  54. Assert.AreEqual(38.30m, opt.ImpliedVolatility);
  55. Assert.AreEqual(0L, opt.OpenInterest, "\"0\" 미결제약정은 0 (null 아님)");
  56. }
  57. [TestMethod]
  58. public void ParseDaily_KosdaqOption_StrikeParsed_KindFromArgument()
  59. {
  60. // 주식옵션(코스닥) eqkop_bydd_trd 샘플 — "씨젠 C 202501 15,500( 10)" → 15500
  61. const string json = """
  62. {"OutBlock_1":[
  63. {"BAS_DD":"20250102","PROD_NM":"씨젠 옵션","RGHT_TP_NM":"CALL","ISU_CD":"2CLW1062","ISU_NM":"씨젠 C 202501 15,500( 10)","TDD_CLSPRC":"","CMPPREVDD_PRC":"","TDD_OPNPRC":"","TDD_HGPRC":"","TDD_LWPRC":"","IMP_VOLT":"48.70","NXTDD_BAS_PRC":"8700.00","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"0"}
  64. ]}
  65. """;
  66. var rows = KrxOptionsParser.ParseDaily(json, OptionsKind.StockKosdaq);
  67. Assert.AreEqual(1, rows.Count);
  68. Assert.AreEqual(OptionsKind.StockKosdaq, rows[0].OptionsKind);
  69. Assert.AreEqual(15500m, rows[0].StrikePrice, "행사가 15,500 → 15500");
  70. }
  71. [TestMethod]
  72. public void ParseDaily_NullsAndUnparsableStrike_AreHandled()
  73. {
  74. // IMP_VOLT/NXTDD_BAS_PRC = "-" → null, 행사가 파싱 불가한 종목명 → StrikePrice null, ACC_OPNINT_QTY "-" → null
  75. const string json = """
  76. {"OutBlock_1":[
  77. {"BAS_DD":"20250102","PROD_NM":"코스피200 옵션","RGHT_TP_NM":"PUT","ISU_CD":"305W9999","ISU_NM":"코스피200 위클리물","TDD_CLSPRC":"","CMPPREVDD_PRC":"","TDD_OPNPRC":"","TDD_HGPRC":"","TDD_LWPRC":"","IMP_VOLT":"-","NXTDD_BAS_PRC":"-","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"-"}
  78. ]}
  79. """;
  80. var rows = KrxOptionsParser.ParseDaily(json, OptionsKind.General);
  81. Assert.AreEqual(1, rows.Count);
  82. var opt = rows[0];
  83. Assert.AreEqual(OptionRightType.Put, opt.RightType);
  84. Assert.IsNull(opt.StrikePrice, "행사가 패턴 없는 종목명 → null");
  85. Assert.IsNull(opt.ImpliedVolatility, "\"-\" 내재변동성은 null");
  86. Assert.IsNull(opt.NextDayBasePrice, "\"-\" 익일정산가는 null");
  87. Assert.IsNull(opt.OpenInterest, "\"-\" 미결제약정은 null");
  88. }
  89. [TestMethod]
  90. public void ParseDaily_UnknownRightType_RowSkipped()
  91. {
  92. // RGHT_TP_NM 이 CALL/PUT 이 아니면(빈값 등) 해당 행 skip
  93. const string json = """
  94. {"OutBlock_1":[
  95. {"BAS_DD":"20250102","PROD_NM":"코스피200 옵션","RGHT_TP_NM":"","ISU_CD":"205W1232","ISU_NM":"코스피 C 202501 300.0","TDD_CLSPRC":"1.0","CMPPREVDD_PRC":"0","TDD_OPNPRC":"1.0","TDD_HGPRC":"1.0","TDD_LWPRC":"1.0","IMP_VOLT":"3.0","NXTDD_BAS_PRC":"1.0","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"0"}
  96. ]}
  97. """;
  98. Assert.AreEqual(0, KrxOptionsParser.ParseDaily(json, OptionsKind.General).Count, "권리유형 미상 행은 skip");
  99. }
  100. [TestMethod]
  101. public void ParseDaily_EmptyOrMissingBlock_ReturnsEmpty()
  102. {
  103. Assert.AreEqual(0, KrxOptionsParser.ParseDaily("""{"OutBlock_1":[]}""", OptionsKind.General).Count);
  104. Assert.AreEqual(0, KrxOptionsParser.ParseDaily("""{"respMsg":"Unauthorized API Call","respCode":"401"}""", OptionsKind.StockKospi).Count);
  105. }
  106. [TestMethod]
  107. public void ParseDaily_SessionMapping_FromIsuName_NoMktNmField()
  108. {
  109. // 일반옵션(opt_bydd_trd)은 MKT_NM 필드 없이 동일 ISU_CD 로 정규·야간 행이 각각 온다 (라이브 확인: 18,908행 = 9,454 코드 × 2).
  110. // 세션 표기는 종목명 "(정규)"/"(야간)" → Regular/Night, 표기 없는 위클리 변형은 Regular (설계된 degrade)
  111. const string json = """
  112. {"OutBlock_1":[
  113. {"BAS_DD":"20260706","PROD_NM":"미니코스피200 옵션","RGHT_TP_NM":"CALL","ISU_CD":"205WA232","ISU_NM":"미니코스피 C 202607 232.5 (정규)","TDD_CLSPRC":"1.50","CMPPREVDD_PRC":"0.10","TDD_OPNPRC":"1.40","TDD_HGPRC":"1.55","TDD_LWPRC":"1.38","IMP_VOLT":"12.00","NXTDD_BAS_PRC":"1.50","ACC_TRDVOL":"1,000","ACC_TRDVAL":"150,000,000","ACC_OPNINT_QTY":"500"},
  114. {"BAS_DD":"20260706","PROD_NM":"미니코스피200 옵션","RGHT_TP_NM":"CALL","ISU_CD":"205WA232","ISU_NM":"미니코스피 C 202607 232.5 (야간)","TDD_CLSPRC":"1.45","CMPPREVDD_PRC":"-0.05","TDD_OPNPRC":"1.50","TDD_HGPRC":"1.50","TDD_LWPRC":"1.44","IMP_VOLT":"12.10","NXTDD_BAS_PRC":"1.45","ACC_TRDVOL":"10","ACC_TRDVAL":"1,500,000","ACC_OPNINT_QTY":"500"},
  115. {"BAS_DD":"20260706","PROD_NM":"코스피200 위클리 옵션","RGHT_TP_NM":"PUT","ISU_CD":"BAK36002","ISU_NM":"코스피200위클리(목) P 1,325","TDD_CLSPRC":"","CMPPREVDD_PRC":"","TDD_OPNPRC":"","TDD_HGPRC":"","TDD_LWPRC":"","IMP_VOLT":"-","NXTDD_BAS_PRC":"-","ACC_TRDVOL":"0","ACC_TRDVAL":"0","ACC_OPNINT_QTY":"0"}
  116. ]}
  117. """;
  118. var rows = KrxOptionsParser.ParseDaily(json, OptionsKind.General);
  119. Assert.AreEqual(3, rows.Count);
  120. Assert.AreEqual(MarketSession.Regular, rows[0].Session, "종목명 (정규) → Regular");
  121. Assert.AreEqual(MarketSession.Night, rows[1].Session, "종목명 (야간) → Night (MKT_NM 없이도 판정)");
  122. Assert.AreEqual(rows[0].IsuCode, rows[1].IsuCode, "동일 종목코드가 세션별 별도 행으로 유지");
  123. Assert.AreEqual(1.50m, rows[0].Close, "정규 종가 보존");
  124. Assert.AreEqual(1.45m, rows[1].Close, "야간 종가 보존");
  125. Assert.AreEqual(MarketSession.Regular, rows[2].Session, "세션 표기 없는 위클리 변형 → Regular");
  126. }
  127. }