@@ -411,13 +411,261 @@ public void UnrealizedPnL_ByDataType()
411411 public void SaveLoad ( )
412412 {
413413 var manager = new PnLManager { UseLevel1 = true } ;
414-
414+
415415 var storage = manager . Save ( ) ;
416416
417417 var manager2 = new PnLManager ( ) ;
418-
418+
419419 manager2 . UseLevel1 . AssertFalse ( ) ;
420420 manager2 . Load ( storage ) ;
421421 manager2 . UseLevel1 . AssertTrue ( ) ;
422422 }
423+
424+ [ TestMethod ]
425+ public void StalePrices_QuoteThenCandle ( )
426+ {
427+ var secId = Helper . CreateSecurityId ( ) ;
428+
429+ IPnLManager manager = new PnLManager
430+ {
431+ UseOrderBook = true ,
432+ UseCandles = true
433+ } ;
434+
435+ var reg = new OrderRegisterMessage
436+ {
437+ PortfolioName = Helper . CreatePortfolio ( ) . Name ,
438+ SecurityId = secId ,
439+ TransactionId = 1 ,
440+ } ;
441+ manager . ProcessMessage ( reg ) ;
442+
443+ // Open long position: buy 1 at 100
444+ var buy = new ExecutionMessage
445+ {
446+ OriginalTransactionId = reg . TransactionId ,
447+ DataTypeEx = DataType . Transactions ,
448+ SecurityId = secId ,
449+ TradeId = 1 ,
450+ TradePrice = 100m ,
451+ TradeVolume = 1m ,
452+ Side = Sides . Buy ,
453+ ServerTime = DateTimeOffset . UtcNow
454+ } ;
455+ manager . ProcessMessage ( buy ) ;
456+
457+ // Market data: quote with bid=130, ask=131
458+ var quote = new QuoteChangeMessage
459+ {
460+ SecurityId = secId ,
461+ Bids = [ new ( 130m , 1 ) ] ,
462+ Asks = [ new ( 131m , 1 ) ]
463+ } ;
464+ manager . ProcessMessage ( quote ) ;
465+ manager . UnrealizedPnL . AssertEqual ( 30m ) ; // (130-100)*1 = 30
466+
467+ // Newer market data: candle closes at 150
468+ // BUG: ProcessCandle doesn't clear bid/ask prices, so UnrealizedPnL
469+ // continues to use stale bid=130 instead of fresh close=150
470+ var candle = new TimeFrameCandleMessage
471+ {
472+ SecurityId = secId ,
473+ OpenTime = DateTimeOffset . Now ,
474+ CloseTime = DateTimeOffset . Now ,
475+ OpenPrice = 100 ,
476+ HighPrice = 150 ,
477+ LowPrice = 90 ,
478+ ClosePrice = 150 ,
479+ TotalVolume = 1
480+ } ;
481+ manager . ProcessMessage ( candle ) ;
482+
483+ // Expected: (150-100)*1 = 50
484+ // Actual: (130-100)*1 = 30 (uses stale bid instead of fresh close price)
485+ manager . UnrealizedPnL . AssertEqual ( 50m ) ;
486+ }
487+
488+ [ TestMethod ]
489+ public void StalePrices_CandleThenQuote ( )
490+ {
491+ var secId = Helper . CreateSecurityId ( ) ;
492+
493+ IPnLManager manager = new PnLManager
494+ {
495+ UseOrderBook = true ,
496+ UseCandles = true
497+ } ;
498+
499+ var reg = new OrderRegisterMessage
500+ {
501+ PortfolioName = Helper . CreatePortfolio ( ) . Name ,
502+ SecurityId = secId ,
503+ TransactionId = 1 ,
504+ } ;
505+ manager . ProcessMessage ( reg ) ;
506+
507+ // Open short position: sell 1 at 100
508+ var sell = new ExecutionMessage
509+ {
510+ OriginalTransactionId = reg . TransactionId ,
511+ DataTypeEx = DataType . Transactions ,
512+ SecurityId = secId ,
513+ TradeId = 1 ,
514+ TradePrice = 100m ,
515+ TradeVolume = 1m ,
516+ Side = Sides . Sell ,
517+ ServerTime = DateTimeOffset . UtcNow
518+ } ;
519+ manager . ProcessMessage ( sell ) ;
520+
521+ // Market data: candle closes at 90
522+ var candle = new TimeFrameCandleMessage
523+ {
524+ SecurityId = secId ,
525+ OpenTime = DateTimeOffset . Now ,
526+ CloseTime = DateTimeOffset . Now ,
527+ OpenPrice = 100 ,
528+ HighPrice = 110 ,
529+ LowPrice = 85 ,
530+ ClosePrice = 90 ,
531+ TotalVolume = 1
532+ } ;
533+ manager . ProcessMessage ( candle ) ;
534+ manager . UnrealizedPnL . AssertEqual ( 10m ) ; // (100-90)*1 = 10
535+
536+ // Newer market data: quote with ask=80
537+ // BUG: ProcessQuotes doesn't clear lastPrice, so if ask is not available,
538+ // UnrealizedPnL uses stale close=90 instead of fresh ask=80
539+ var quote = new QuoteChangeMessage
540+ {
541+ SecurityId = secId ,
542+ Bids = [ new ( 79m , 1 ) ] ,
543+ Asks = [ new ( 80m , 1 ) ]
544+ } ;
545+ manager . ProcessMessage ( quote ) ;
546+
547+ // For short position, uses ask price for UnrealizedPnL
548+ // Expected: (100-80)*1 = 20
549+ manager . UnrealizedPnL . AssertEqual ( 20m ) ;
550+ }
551+
552+ [ TestMethod ]
553+ public void StalePrices_TickThenQuote ( )
554+ {
555+ var secId = Helper . CreateSecurityId ( ) ;
556+
557+ IPnLManager manager = new PnLManager
558+ {
559+ UseTick = true ,
560+ UseOrderBook = true ,
561+ } ;
562+
563+ var reg = new OrderRegisterMessage
564+ {
565+ PortfolioName = Helper . CreatePortfolio ( ) . Name ,
566+ SecurityId = secId ,
567+ TransactionId = 1 ,
568+ } ;
569+ manager . ProcessMessage ( reg ) ;
570+
571+ // Open long position: buy 1 at 100
572+ var buy = new ExecutionMessage
573+ {
574+ OriginalTransactionId = reg . TransactionId ,
575+ DataTypeEx = DataType . Transactions ,
576+ SecurityId = secId ,
577+ TradeId = 1 ,
578+ TradePrice = 100m ,
579+ TradeVolume = 1m ,
580+ Side = Sides . Buy ,
581+ ServerTime = DateTimeOffset . UtcNow
582+ } ;
583+ manager . ProcessMessage ( buy ) ;
584+
585+ // Market data: tick at 140
586+ var tick = new ExecutionMessage
587+ {
588+ DataTypeEx = DataType . Ticks ,
589+ SecurityId = secId ,
590+ TradePrice = 140m ,
591+ ServerTime = DateTimeOffset . UtcNow
592+ } ;
593+ manager . ProcessMessage ( tick ) ;
594+ manager . UnrealizedPnL . AssertEqual ( 40m ) ; // (140-100)*1 = 40
595+
596+ // Newer market data: quote with bid=130
597+ // BUG: ProcessQuotes doesn't clear lastPrice, so UnrealizedPnL
598+ // prefers bid=130 over stale lastPrice=140
599+ var quote = new QuoteChangeMessage
600+ {
601+ SecurityId = secId ,
602+ Bids = [ new ( 130m , 1 ) ] ,
603+ Asks = [ new ( 131m , 1 ) ]
604+ } ;
605+ manager . ProcessMessage ( quote ) ;
606+
607+ // For long position, uses bid price (which is more accurate than last trade)
608+ // Expected: (130-100)*1 = 30
609+ manager . UnrealizedPnL . AssertEqual ( 30m ) ;
610+ }
611+
612+ [ TestMethod ]
613+ public void StalePrices_QuoteThenTick ( )
614+ {
615+ var secId = Helper . CreateSecurityId ( ) ;
616+
617+ IPnLManager manager = new PnLManager
618+ {
619+ UseTick = true ,
620+ UseOrderBook = true ,
621+ } ;
622+
623+ var reg = new OrderRegisterMessage
624+ {
625+ PortfolioName = Helper . CreatePortfolio ( ) . Name ,
626+ SecurityId = secId ,
627+ TransactionId = 1 ,
628+ } ;
629+ manager . ProcessMessage ( reg ) ;
630+
631+ // Open long position: buy 1 at 100
632+ var buy = new ExecutionMessage
633+ {
634+ OriginalTransactionId = reg . TransactionId ,
635+ DataTypeEx = DataType . Transactions ,
636+ SecurityId = secId ,
637+ TradeId = 1 ,
638+ TradePrice = 100m ,
639+ TradeVolume = 1m ,
640+ Side = Sides . Buy ,
641+ ServerTime = DateTimeOffset . UtcNow
642+ } ;
643+ manager . ProcessMessage ( buy ) ;
644+
645+ // Market data: quote with bid=130
646+ var quote = new QuoteChangeMessage
647+ {
648+ SecurityId = secId ,
649+ Bids = [ new ( 130m , 1 ) ] ,
650+ Asks = [ new ( 131m , 1 ) ]
651+ } ;
652+ manager . ProcessMessage ( quote ) ;
653+ manager . UnrealizedPnL . AssertEqual ( 30m ) ; // (130-100)*1 = 30
654+
655+ // Newer market data: tick at 150
656+ // BUG: ProcessExecution doesn't clear bid/ask prices, so UnrealizedPnL
657+ // continues to use stale bid=130 instead of fresh tick=150
658+ var tick = new ExecutionMessage
659+ {
660+ DataTypeEx = DataType . Ticks ,
661+ SecurityId = secId ,
662+ TradePrice = 150m ,
663+ ServerTime = DateTimeOffset . UtcNow
664+ } ;
665+ manager . ProcessMessage ( tick ) ;
666+
667+ // Expected: (150-100)*1 = 50
668+ // Actual: (130-100)*1 = 30 (uses stale bid instead of fresh tick)
669+ manager . UnrealizedPnL . AssertEqual ( 50m ) ;
670+ }
423671}
0 commit comments