Skip to content

Commit 8d2233e

Browse files
committed
Add comprehensive tests for stale price bug in PnL calculation
Added 4 test cases that demonstrate the stale price bug in different scenarios: 1. StalePrices_QuoteThenCandle - Quote sets bid/ask, then Candle sets lastPrice but doesn't clear bid/ask, causing UnrealizedPnL to use stale bid 2. StalePrices_CandleThenQuote - Candle sets lastPrice, then Quote sets bid/ask but doesn't clear lastPrice (though bid/ask should be preferred) 3. StalePrices_TickThenQuote - Tick sets lastPrice, then Quote sets bid/ask but doesn't clear lastPrice (bid/ask should be preferred anyway) 4. StalePrices_QuoteThenTick - Quote sets bid/ask, then Tick sets lastPrice but doesn't clear bid/ask, causing UnrealizedPnL to use stale bid The root cause: ProcessCandle, ProcessQuotes, and ProcessExecution methods don't reset other price fields when updating their respective prices. Only ProcessLevel1 correctly resets all prices (as it's a full snapshot). All test comments are in English as requested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5ea6186 commit 8d2233e

1 file changed

Lines changed: 190 additions & 5 deletions

File tree

Tests/PnLTests.cs

Lines changed: 190 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ public void StalePrices_QuoteThenCandle()
440440
};
441441
manager.ProcessMessage(reg);
442442

443-
// Buy 1 at 100
443+
// Open long position: buy 1 at 100
444444
var buy = new ExecutionMessage
445445
{
446446
OriginalTransactionId = reg.TransactionId,
@@ -454,7 +454,7 @@ public void StalePrices_QuoteThenCandle()
454454
};
455455
manager.ProcessMessage(buy);
456456

457-
// Quote: bid=130, ask=131
457+
// Market data: quote with bid=130, ask=131
458458
var quote = new QuoteChangeMessage
459459
{
460460
SecurityId = secId,
@@ -464,7 +464,9 @@ public void StalePrices_QuoteThenCandle()
464464
manager.ProcessMessage(quote);
465465
manager.UnrealizedPnL.AssertEqual(30m); // (130-100)*1 = 30
466466

467-
// Candle closes at 150 (newer data!)
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
468470
var candle = new TimeFrameCandleMessage
469471
{
470472
SecurityId = secId,
@@ -478,9 +480,192 @@ public void StalePrices_QuoteThenCandle()
478480
};
479481
manager.ProcessMessage(candle);
480482

481-
// BUG: UnrealizedPnL still uses stale bid=130 instead of fresh close=150
482483
// Expected: (150-100)*1 = 50
483-
// Actual: (130-100)*1 = 30 ❌
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)
484669
manager.UnrealizedPnL.AssertEqual(50m);
485670
}
486671
}

0 commit comments

Comments
 (0)