├── .github ├── dependabot.yml └── workflows │ └── pr_validation.yaml ├── .gitignore ├── Akka.CQRS.sln ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── appsettings.json ├── build-system ├── README.md ├── azure-pipeline.template.yaml ├── linux-pr-validation.yaml ├── windows-pr-validation.yaml └── windows-release.yaml ├── build.cmd ├── build.fsx ├── build.ps1 ├── build.sh ├── docker-compose.yaml ├── docs ├── api │ └── index.md ├── articles │ ├── index.md │ └── toc.yml ├── docfx.json ├── images │ ├── akka-cqrs-architectural-overview.png │ ├── akka-cqrs-inmemory-replication.png │ ├── docker-for-windows-networking.png │ └── icon.png ├── index.md ├── toc.yml └── web.config ├── global.json ├── serve-docs.cmd ├── serve-docs.ps1 └── src ├── Akka.CQRS.Infrastructure.Tests ├── Akka.CQRS.Infrastructure.Tests.csproj └── ConfigSpecs.cs ├── Akka.CQRS.Infrastructure ├── Akka.CQRS.Infrastructure.csproj ├── Ops │ ├── OpsConfig.cs │ └── ops.conf ├── SqlDbHoconHelper.cs ├── StockEventTagger.cs └── StockShardMsgRouter.cs ├── Akka.CQRS.Matching.Tests ├── Akka.CQRS.Matching.Tests.csproj └── MatchingEngineSpecs.cs ├── Akka.CQRS.Matching ├── Akka.CQRS.Matching.csproj └── MatchingEngine.cs ├── Akka.CQRS.Pricing.Actors ├── Akka.CQRS.Pricing.Actors.csproj ├── MatchAggregator.cs ├── PriceInitiatorActor.cs ├── PriceViewActor.cs ├── PriceViewMaster.cs └── UnexpectedEndOfStream.cs ├── Akka.CQRS.Pricing.Cli ├── Akka.CQRS.Pricing.Cli.csproj ├── PriceCmdHandler.cs ├── PriceCmdRouter.cs ├── PriceTrackingActor.cs └── PricingCmd.cs ├── Akka.CQRS.Pricing.Service ├── Akka.CQRS.Pricing.Service.csproj ├── Dockerfile ├── Program.cs └── app.conf ├── Akka.CQRS.Pricing ├── Akka.CQRS.Pricing.csproj ├── Commands │ ├── FetchPriceAndVolume.cs │ ├── GetPriceHistory.cs │ ├── Ping.cs │ └── PriceAndVolumeSnapshot.cs ├── Events │ ├── IPriceUpdate.cs │ ├── IVolumeUpdate.cs │ ├── PriceChanged.cs │ └── VolumeChanged.cs ├── MatchAggregatorSnapshot.cs ├── PriceTopicHelpers.cs └── Views │ ├── EMWA.cs │ ├── MatchAggregate.cs │ └── PriceHistory.cs ├── Akka.CQRS.Subscriptions.Tests ├── Akka.CQRS.Subscriptions.Tests.csproj ├── DistributedPubSub │ ├── DistributedPubSubEnd2EndSpecs.cs │ └── DistributedPubSubFormatterSpecs.cs └── TradeEventExtensionsSpecs.cs ├── Akka.CQRS.Subscriptions ├── Akka.CQRS.Subscriptions.csproj ├── DistributedPubSub │ ├── DistributedPubSubTopicFormatter.cs │ ├── DistributedPubSubTradeEventPublisher.cs │ └── DistributedPubSubTradeEventSubscriptionManager.cs ├── ITradeEventPublisher.cs ├── ITradeEventSubscriptionManager.cs ├── InMem │ └── InMemoryTradeEventPublisher.cs ├── NoOp │ └── NoOpTradeEventSubscriptionManager.cs ├── TradeEventHelpers.cs ├── TradeEventType.cs ├── TradeSubscribe.cs ├── TradeSubscribeAck.cs ├── TradeSubscribeNack.cs ├── TradeUnsubscribe.cs ├── TradeUnsubscribeAck.cs └── TradeUnsubscribeNack.cs ├── Akka.CQRS.Tests ├── Akka.CQRS.Tests.csproj └── OrderSpecs.cs ├── Akka.CQRS.TradePlacers.Service ├── Akka.CQRS.TradePlacers.Service.csproj ├── Dockerfile ├── Program.cs └── app.conf ├── Akka.CQRS.TradeProcessor.Actors ├── Akka.CQRS.TradeProcessor.Actors.csproj ├── AskerActor.cs ├── BidderActor.cs └── OrderBookActor.cs ├── Akka.CQRS.TradeProcessor.Service ├── Akka.CQRS.TradeProcessor.Service.csproj ├── Dockerfile ├── Program.cs └── app.conf ├── Akka.CQRS ├── Akka.CQRS.csproj ├── AvailableTickerSymbols.cs ├── Commands │ ├── GetOrderBookSnapshot.cs │ └── GetRecentMatches.cs ├── Entities │ ├── Order.cs │ └── OrderExtensions.cs ├── EntityIdHelper.cs ├── Events │ ├── Ask.cs │ ├── Bid.cs │ ├── Fill.cs │ └── Match.cs ├── ITimestamper.cs ├── ITradeEvent.cs ├── ITradeOrderGenerator.cs ├── IWithOrderId.cs ├── IWithStockId.cs ├── OrderbookSnapshot.cs ├── PriceRange.cs ├── PriceRangeExtensions.cs └── Util │ ├── CurrentUtcTimestamper.cs │ └── GuidTradeOrderIdGenerator.cs ├── Directory.Build.props └── Directory.Packages.props /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/pr_validation.yaml: -------------------------------------------------------------------------------- 1 | name: pr_validation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | - main 9 | pull_request: 10 | branches: 11 | - master 12 | - dev 13 | - main 14 | 15 | jobs: 16 | test: 17 | name: Test-${{matrix.os}} 18 | runs-on: ${{matrix.os}} 19 | 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, windows-latest] 23 | 24 | steps: 25 | - name: "Checkout" 26 | uses: actions/checkout@v3.0.2 27 | with: 28 | lfs: true 29 | fetch-depth: 0 30 | 31 | - name: "Install .NET SDK" 32 | uses: actions/setup-dotnet@v2.1.0 33 | with: 34 | dotnet-version: | 35 | 6.0.x 36 | global-json-file: "./global.json" 37 | 38 | - name: "dotnet build" 39 | run: dotnet build -c Release 40 | 41 | - name: "dotnet test" 42 | run: dotnet test -c Release -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | tools/ 3 | build/ 4 | .nuget/ 5 | .dotnet/ 6 | .idea/ 7 | .[Dd][Ss]_[Ss]tore 8 | 9 | ## NBench output 10 | [Pp]erf[Rr]esult*/ 11 | 12 | ## Ignore Visual Studio temporary files, build results, and 13 | ## files generated by popular Visual Studio add-ons. 14 | ## 15 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 16 | 17 | # User-specific files 18 | *.suo 19 | *.user 20 | *.userosscache 21 | *.sln.docstates 22 | 23 | # User-specific files (MonoDevelop/Xamarin Studio) 24 | *.userprefs 25 | 26 | # Build results 27 | [Dd]ebug/ 28 | [Dd]ebugPublic/ 29 | [Rr]elease/ 30 | [Rr]eleases/ 31 | x64/ 32 | x86/ 33 | bld/ 34 | [Bb]in/ 35 | [Oo]bj/ 36 | [Ll]og/ 37 | 38 | #FAKE 39 | .fake 40 | tools/ 41 | 42 | #DocFx output 43 | _site/ 44 | 45 | # Visual Studio 2015 cache/options directory 46 | .vs/ 47 | # Uncomment if you have tasks that create the project's static files in wwwroot 48 | #wwwroot/ 49 | 50 | # MSTest test Results 51 | [Tt]est[Rr]esult*/ 52 | [Bb]uild[Ll]og.* 53 | 54 | # NUNIT 55 | *.VisualState.xml 56 | TestResult.xml 57 | 58 | # Build Results of an ATL Project 59 | [Dd]ebugPS/ 60 | [Rr]eleasePS/ 61 | dlldata.c 62 | 63 | # .NET Core 64 | project.lock.json 65 | project.fragment.lock.json 66 | artifacts/ 67 | **/Properties/launchSettings.json 68 | 69 | *_i.c 70 | *_p.c 71 | *_i.h 72 | *.ilk 73 | *.meta 74 | *.obj 75 | *.pch 76 | *.pdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opendb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | *.VC.db 106 | *.VC.VC.opendb 107 | 108 | # Visual Studio profiler 109 | *.psess 110 | *.vsp 111 | *.vspx 112 | *.sap 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # Visual Studio code coverage results 135 | *.coverage 136 | *.coveragexml 137 | 138 | # NCrunch 139 | _NCrunch_* 140 | .*crunch*.local.xml 141 | nCrunchTemp_* 142 | 143 | # MightyMoose 144 | *.mm.* 145 | AutoTest.Net/ 146 | 147 | # Web workbench (sass) 148 | .sass-cache/ 149 | 150 | # Installshield output folder 151 | [Ee]xpress/ 152 | 153 | # DocProject is a documentation generator add-in 154 | DocProject/buildhelp/ 155 | DocProject/Help/*.HxT 156 | DocProject/Help/*.HxC 157 | DocProject/Help/*.hhc 158 | DocProject/Help/*.hhk 159 | DocProject/Help/*.hhp 160 | DocProject/Help/Html2 161 | DocProject/Help/html 162 | 163 | # Click-Once directory 164 | publish/ 165 | 166 | # Publish Web Output 167 | *.[Pp]ublish.xml 168 | *.azurePubxml 169 | # TODO: Comment the next line if you want to checkin your web deploy settings 170 | # but database connection strings (with potential passwords) will be unencrypted 171 | *.pubxml 172 | *.publishproj 173 | 174 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 175 | # checkin your Azure Web App publish settings, but sensitive information contained 176 | # in these scripts will be unencrypted 177 | PublishScripts/ 178 | 179 | # NuGet Packages 180 | *.nupkg 181 | # The packages folder can be ignored because of Package Restore 182 | **/packages/* 183 | # except build/, which is used as an MSBuild target. 184 | !**/packages/build/ 185 | # Uncomment if necessary however generally it will be regenerated when needed 186 | #!**/packages/repositories.config 187 | # NuGet v3's project.json files produces more ignorable files 188 | *.nuget.props 189 | *.nuget.targets 190 | 191 | # Microsoft Azure Build Output 192 | csx/ 193 | *.build.csdef 194 | 195 | # Microsoft Azure Emulator 196 | ecf/ 197 | rcf/ 198 | 199 | # Windows Store app package directories and files 200 | AppPackages/ 201 | BundleArtifacts/ 202 | Package.StoreAssociation.xml 203 | _pkginfo.txt 204 | 205 | # Visual Studio cache files 206 | # files ending in .cache can be ignored 207 | *.[Cc]ache 208 | # but keep track of directories ending in .cache 209 | !*.[Cc]ache/ 210 | 211 | # Others 212 | ClientBin/ 213 | ~$* 214 | *~ 215 | *.dbmdl 216 | *.dbproj.schemaview 217 | *.jfm 218 | *.pfx 219 | *.publishsettings 220 | orleans.codegen.cs 221 | 222 | # Since there are multiple workflows, uncomment next line to ignore bower_components 223 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 224 | #bower_components/ 225 | 226 | # RIA/Silverlight projects 227 | Generated_Code/ 228 | 229 | # Backup & report files from converting an old project file 230 | # to a newer Visual Studio version. Backup files are not needed, 231 | # because we have git ;-) 232 | _UpgradeReport_Files/ 233 | Backup*/ 234 | UpgradeLog*.XML 235 | UpgradeLog*.htm 236 | 237 | # SQL Server files 238 | *.mdf 239 | *.ldf 240 | *.ndf 241 | 242 | # Business Intelligence projects 243 | *.rdl.data 244 | *.bim.layout 245 | *.bim_*.settings 246 | 247 | # Microsoft Fakes 248 | FakesAssemblies/ 249 | 250 | # GhostDoc plugin setting file 251 | *.GhostDoc.xml 252 | 253 | # Node.js Tools for Visual Studio 254 | .ntvs_analysis.dat 255 | node_modules/ 256 | 257 | # Typescript v1 declaration files 258 | typings/ 259 | 260 | # Visual Studio 6 build log 261 | *.plg 262 | 263 | # Visual Studio 6 workspace options file 264 | *.opt 265 | 266 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 267 | *.vbw 268 | 269 | # Visual Studio LightSwitch build output 270 | **/*.HTMLClient/GeneratedArtifacts 271 | **/*.DesktopClient/GeneratedArtifacts 272 | **/*.DesktopClient/ModelManifest.xml 273 | **/*.Server/GeneratedArtifacts 274 | **/*.Server/ModelManifest.xml 275 | _Pvt_Extensions 276 | 277 | # Paket dependency manager 278 | .paket/paket.exe 279 | paket-files/ 280 | 281 | # FAKE - F# Make 282 | .fake/ 283 | 284 | # JetBrains Rider 285 | .idea/ 286 | *.sln.iml 287 | 288 | # CodeRush 289 | .cr/ 290 | 291 | # Python Tools for Visual Studio (PTVS) 292 | __pycache__/ 293 | *.pyc 294 | 295 | # Cake - Uncomment if you are using it 296 | # tools/** 297 | # !tools/packages.config 298 | 299 | # Telerik's JustMock configuration file 300 | *.jmconfig 301 | 302 | # BizTalk build output 303 | *.btp.cs 304 | *.btm.cs 305 | *.odx.cs 306 | *.xsd.cs 307 | -------------------------------------------------------------------------------- /Akka.CQRS.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26430.14 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS", "src\Akka.CQRS\Akka.CQRS.csproj", "{E945AABA-2779-41E8-9B43-8898FFD64F22}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Tests", "src\Akka.CQRS.Tests\Akka.CQRS.Tests.csproj", "{0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{79D71264-186B-4F62-8930-35DD9ECCAF3B}" 11 | ProjectSection(SolutionItems) = preProject 12 | build.cmd = build.cmd 13 | build.fsx = build.fsx 14 | build.ps1 = build.ps1 15 | build.sh = build.sh 16 | EndProjectSection 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Matching", "src\Akka.CQRS.Matching\Akka.CQRS.Matching.csproj", "{F6F74FCA-8E53-4620-90C7-9A91278A3521}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Matching.Tests", "src\Akka.CQRS.Matching.Tests\Akka.CQRS.Matching.Tests.csproj", "{F1EFDCB9-FD61-4782-A12A-108C73DB368E}" 21 | EndProject 22 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "libraries", "libraries", "{08DA7279-F347-4F54-95B6-7335F52746A6}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "trade-platform", "trade-platform", "{E1C0B656-C8EA-4F93-8855-798548B72B13}" 25 | EndProject 26 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.TradeProcessor.Service", "src\Akka.CQRS.TradeProcessor.Service\Akka.CQRS.TradeProcessor.Service.csproj", "{5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE}" 27 | EndProject 28 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.TradeProcessor.Actors", "src\Akka.CQRS.TradeProcessor.Actors\Akka.CQRS.TradeProcessor.Actors.csproj", "{733BA8FC-C198-4C83-BA57-FE3672CBB307}" 29 | EndProject 30 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Subscriptions", "src\Akka.CQRS.Subscriptions\Akka.CQRS.Subscriptions.csproj", "{53895A69-5516-4598-8D76-32F9F2C67F0E}" 31 | EndProject 32 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Infrastructure", "src\Akka.CQRS.Infrastructure\Akka.CQRS.Infrastructure.csproj", "{290432CA-71FE-4045-9503-D294CC2586C4}" 33 | EndProject 34 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.TradePlacers.Service", "src\Akka.CQRS.TradePlacers.Service\Akka.CQRS.TradePlacers.Service.csproj", "{BD6F0E08-1A18-44CE-A121-4AD09D63C0B0}" 35 | EndProject 36 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pricing-platform", "pricing-platform", "{C60F4548-6FFD-4D0E-B8C3-6CD6BC8FBC2C}" 37 | EndProject 38 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Pricing", "src\Akka.CQRS.Pricing\Akka.CQRS.Pricing.csproj", "{A14AB205-AEF1-47ED-9836-D241D69889C4}" 39 | EndProject 40 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Pricing.Actors", "src\Akka.CQRS.Pricing.Actors\Akka.CQRS.Pricing.Actors.csproj", "{F6F32FB5-332F-48C1-8EC4-3FBD313BA411}" 41 | EndProject 42 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Pricing.Service", "src\Akka.CQRS.Pricing.Service\Akka.CQRS.Pricing.Service.csproj", "{ABB3AA71-9C0E-4455-9631-5D59031A8885}" 43 | EndProject 44 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akka.CQRS.Pricing.Cli", "src\Akka.CQRS.Pricing.Cli\Akka.CQRS.Pricing.Cli.csproj", "{333F7F76-8429-487D-BEC8-1AE57ABC7D49}" 45 | EndProject 46 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.CQRS.Infrastructure.Tests", "src\Akka.CQRS.Infrastructure.Tests\Akka.CQRS.Infrastructure.Tests.csproj", "{73E9BD37-D311-42FF-80E5-3E6C38835910}" 47 | EndProject 48 | Global 49 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 50 | Debug|Any CPU = Debug|Any CPU 51 | Release|Any CPU = Release|Any CPU 52 | EndGlobalSection 53 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 54 | {E945AABA-2779-41E8-9B43-8898FFD64F22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {E945AABA-2779-41E8-9B43-8898FFD64F22}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {E945AABA-2779-41E8-9B43-8898FFD64F22}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {E945AABA-2779-41E8-9B43-8898FFD64F22}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {F6F74FCA-8E53-4620-90C7-9A91278A3521}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {F6F74FCA-8E53-4620-90C7-9A91278A3521}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {F6F74FCA-8E53-4620-90C7-9A91278A3521}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {F6F74FCA-8E53-4620-90C7-9A91278A3521}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {F1EFDCB9-FD61-4782-A12A-108C73DB368E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 67 | {F1EFDCB9-FD61-4782-A12A-108C73DB368E}.Debug|Any CPU.Build.0 = Debug|Any CPU 68 | {F1EFDCB9-FD61-4782-A12A-108C73DB368E}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {F1EFDCB9-FD61-4782-A12A-108C73DB368E}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 71 | {5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE}.Debug|Any CPU.Build.0 = Debug|Any CPU 72 | {5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE}.Release|Any CPU.ActiveCfg = Release|Any CPU 73 | {5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE}.Release|Any CPU.Build.0 = Release|Any CPU 74 | {733BA8FC-C198-4C83-BA57-FE3672CBB307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 75 | {733BA8FC-C198-4C83-BA57-FE3672CBB307}.Debug|Any CPU.Build.0 = Debug|Any CPU 76 | {733BA8FC-C198-4C83-BA57-FE3672CBB307}.Release|Any CPU.ActiveCfg = Release|Any CPU 77 | {733BA8FC-C198-4C83-BA57-FE3672CBB307}.Release|Any CPU.Build.0 = Release|Any CPU 78 | {53895A69-5516-4598-8D76-32F9F2C67F0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 79 | {53895A69-5516-4598-8D76-32F9F2C67F0E}.Debug|Any CPU.Build.0 = Debug|Any CPU 80 | {53895A69-5516-4598-8D76-32F9F2C67F0E}.Release|Any CPU.ActiveCfg = Release|Any CPU 81 | {53895A69-5516-4598-8D76-32F9F2C67F0E}.Release|Any CPU.Build.0 = Release|Any CPU 82 | {290432CA-71FE-4045-9503-D294CC2586C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 83 | {290432CA-71FE-4045-9503-D294CC2586C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 84 | {290432CA-71FE-4045-9503-D294CC2586C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 85 | {290432CA-71FE-4045-9503-D294CC2586C4}.Release|Any CPU.Build.0 = Release|Any CPU 86 | {BD6F0E08-1A18-44CE-A121-4AD09D63C0B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 87 | {BD6F0E08-1A18-44CE-A121-4AD09D63C0B0}.Debug|Any CPU.Build.0 = Debug|Any CPU 88 | {BD6F0E08-1A18-44CE-A121-4AD09D63C0B0}.Release|Any CPU.ActiveCfg = Release|Any CPU 89 | {BD6F0E08-1A18-44CE-A121-4AD09D63C0B0}.Release|Any CPU.Build.0 = Release|Any CPU 90 | {A14AB205-AEF1-47ED-9836-D241D69889C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 91 | {A14AB205-AEF1-47ED-9836-D241D69889C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 92 | {A14AB205-AEF1-47ED-9836-D241D69889C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 93 | {A14AB205-AEF1-47ED-9836-D241D69889C4}.Release|Any CPU.Build.0 = Release|Any CPU 94 | {F6F32FB5-332F-48C1-8EC4-3FBD313BA411}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 95 | {F6F32FB5-332F-48C1-8EC4-3FBD313BA411}.Debug|Any CPU.Build.0 = Debug|Any CPU 96 | {F6F32FB5-332F-48C1-8EC4-3FBD313BA411}.Release|Any CPU.ActiveCfg = Release|Any CPU 97 | {F6F32FB5-332F-48C1-8EC4-3FBD313BA411}.Release|Any CPU.Build.0 = Release|Any CPU 98 | {ABB3AA71-9C0E-4455-9631-5D59031A8885}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 99 | {ABB3AA71-9C0E-4455-9631-5D59031A8885}.Debug|Any CPU.Build.0 = Debug|Any CPU 100 | {ABB3AA71-9C0E-4455-9631-5D59031A8885}.Release|Any CPU.ActiveCfg = Release|Any CPU 101 | {ABB3AA71-9C0E-4455-9631-5D59031A8885}.Release|Any CPU.Build.0 = Release|Any CPU 102 | {333F7F76-8429-487D-BEC8-1AE57ABC7D49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 103 | {333F7F76-8429-487D-BEC8-1AE57ABC7D49}.Debug|Any CPU.Build.0 = Debug|Any CPU 104 | {333F7F76-8429-487D-BEC8-1AE57ABC7D49}.Release|Any CPU.ActiveCfg = Release|Any CPU 105 | {333F7F76-8429-487D-BEC8-1AE57ABC7D49}.Release|Any CPU.Build.0 = Release|Any CPU 106 | {73E9BD37-D311-42FF-80E5-3E6C38835910}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 107 | {73E9BD37-D311-42FF-80E5-3E6C38835910}.Debug|Any CPU.Build.0 = Debug|Any CPU 108 | {73E9BD37-D311-42FF-80E5-3E6C38835910}.Release|Any CPU.ActiveCfg = Release|Any CPU 109 | {73E9BD37-D311-42FF-80E5-3E6C38835910}.Release|Any CPU.Build.0 = Release|Any CPU 110 | EndGlobalSection 111 | GlobalSection(SolutionProperties) = preSolution 112 | HideSolutionNode = FALSE 113 | EndGlobalSection 114 | GlobalSection(NestedProjects) = preSolution 115 | {E945AABA-2779-41E8-9B43-8898FFD64F22} = {08DA7279-F347-4F54-95B6-7335F52746A6} 116 | {0F9B9BC6-9F86-40E8-BA9B-D27BF3AC7970} = {08DA7279-F347-4F54-95B6-7335F52746A6} 117 | {F6F74FCA-8E53-4620-90C7-9A91278A3521} = {08DA7279-F347-4F54-95B6-7335F52746A6} 118 | {F1EFDCB9-FD61-4782-A12A-108C73DB368E} = {08DA7279-F347-4F54-95B6-7335F52746A6} 119 | {5EB6F2C5-FEF1-402A-AB76-3A9F62B2EFDE} = {E1C0B656-C8EA-4F93-8855-798548B72B13} 120 | {733BA8FC-C198-4C83-BA57-FE3672CBB307} = {E1C0B656-C8EA-4F93-8855-798548B72B13} 121 | {53895A69-5516-4598-8D76-32F9F2C67F0E} = {08DA7279-F347-4F54-95B6-7335F52746A6} 122 | {290432CA-71FE-4045-9503-D294CC2586C4} = {08DA7279-F347-4F54-95B6-7335F52746A6} 123 | {BD6F0E08-1A18-44CE-A121-4AD09D63C0B0} = {E1C0B656-C8EA-4F93-8855-798548B72B13} 124 | {A14AB205-AEF1-47ED-9836-D241D69889C4} = {C60F4548-6FFD-4D0E-B8C3-6CD6BC8FBC2C} 125 | {F6F32FB5-332F-48C1-8EC4-3FBD313BA411} = {C60F4548-6FFD-4D0E-B8C3-6CD6BC8FBC2C} 126 | {ABB3AA71-9C0E-4455-9631-5D59031A8885} = {C60F4548-6FFD-4D0E-B8C3-6CD6BC8FBC2C} 127 | {333F7F76-8429-487D-BEC8-1AE57ABC7D49} = {C60F4548-6FFD-4D0E-B8C3-6CD6BC8FBC2C} 128 | {73E9BD37-D311-42FF-80E5-3E6C38835910} = {08DA7279-F347-4F54-95B6-7335F52746A6} 129 | EndGlobalSection 130 | GlobalSection(ExtensibilityGlobals) = postSolution 131 | SolutionGuid = {B99E6BB8-642A-4A68-86DF-69567CBA700A} 132 | EndGlobalSection 133 | EndGlobal 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015-2017 {user} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | #### 0.1.0 August 14 2017 #### 2 | First release -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "SignClient": { 3 | "AzureAd": { 4 | "AADInstance": "https://login.microsoftonline.com/", 5 | "ClientId": "", 6 | "TenantId": "" 7 | }, 8 | "Service": { 9 | "Url": "", 10 | "ResourceId": "" 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /build-system/README.md: -------------------------------------------------------------------------------- 1 | # Azure Pipelines Build Files 2 | These `.yaml` files are used by Windows Azure DevOps Pipelines to help execute the following types of builds: 3 | 4 | - Pull request validation on Linux (Mono / .NET Core) 5 | - Pull request validation on Windows (.NET Framework / .NET Core) 6 | - NuGet releases with automatic release notes posted to a Github Release repository. 7 | 8 | **NOTE**: you will need to change some of the pipeline variables inside the `windows-release.yaml` for your specific project and you will also want to create variable groups with your signing and NuGet push information. -------------------------------------------------------------------------------- /build-system/azure-pipeline.template.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | name: '' 3 | vmImage: '' 4 | scriptFileName: '' 5 | scriptArgs: 'all' 6 | timeoutInMinutes: 120 7 | 8 | jobs: 9 | - job: ${{ parameters.name }} 10 | timeoutInMinutes: ${{ parameters.timeoutInMinutes }} 11 | pool: 12 | vmImage: ${{ parameters.vmImage }} 13 | steps: 14 | - checkout: self # self represents the repo where the initial Pipelines YAML file was found 15 | clean: false # whether to fetch clean each time 16 | submodules: recursive # set to 'true' for a single level of submodules or 'recursive' to get submodules of submodules 17 | persistCredentials: true 18 | # Linux or macOS 19 | - task: Bash@3 20 | displayName: Linux / OSX Build 21 | inputs: 22 | filePath: ${{ parameters.scriptFileName }} 23 | arguments: ${{ parameters.scriptArgs }} 24 | continueOnError: true 25 | condition: in( variables['Agent.OS'], 'Linux', 'Darwin' ) 26 | # Windows 27 | - task: BatchScript@1 28 | displayName: Windows Build 29 | inputs: 30 | filename: ${{ parameters.scriptFileName }} 31 | arguments: ${{ parameters.scriptArgs }} 32 | continueOnError: true 33 | condition: eq( variables['Agent.OS'], 'Windows_NT' ) 34 | - task: PublishTestResults@2 35 | inputs: 36 | testRunner: VSTest 37 | testResultsFiles: '**/*.trx' #TestResults folder usually 38 | testRunTitle: ${{ parameters.name }} 39 | mergeTestResults: true 40 | - script: 'echo 1>&2' 41 | failOnStderr: true 42 | displayName: 'If above is partially succeeded, then fail' 43 | condition: eq(variables['Agent.JobStatus'], 'SucceededWithIssues') -------------------------------------------------------------------------------- /build-system/linux-pr-validation.yaml: -------------------------------------------------------------------------------- 1 | # Pull request validation for Linux against the `dev` and `master` branches 2 | # See https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema for reference 3 | trigger: 4 | branches: 5 | include: 6 | - dev 7 | - master 8 | 9 | name: $(Year:yyyy).$(Month).$(DayOfMonth)$(Rev:.r) 10 | 11 | pr: 12 | autoCancel: true # indicates whether additional pushes to a PR should cancel in-progress runs for the same PR. Defaults to true 13 | branches: 14 | include: [ dev, master ] # branch names which will trigger a build 15 | 16 | jobs: 17 | - template: azure-pipeline.template.yaml 18 | parameters: 19 | name: Ubuntu 20 | vmImage: 'ubuntu-22.04' 21 | scriptFileName: ./build.sh 22 | scriptArgs: all -------------------------------------------------------------------------------- /build-system/windows-pr-validation.yaml: -------------------------------------------------------------------------------- 1 | # Pull request validation for Windows against the `dev` and `master` branches 2 | # See https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema for reference 3 | trigger: 4 | branches: 5 | include: 6 | - dev 7 | - master 8 | 9 | pr: 10 | autoCancel: true # indicates whether additional pushes to a PR should cancel in-progress runs for the same PR. Defaults to true 11 | branches: 12 | include: [ dev, master ] # branch names which will trigger a build 13 | 14 | name: $(Year:yyyy).$(Month).$(DayOfMonth)$(Rev:.r) 15 | 16 | jobs: 17 | - template: azure-pipeline.template.yaml 18 | parameters: 19 | name: Windows 20 | vmImage: 'windows-latest' 21 | scriptFileName: build.cmd 22 | scriptArgs: all -------------------------------------------------------------------------------- /build-system/windows-release.yaml: -------------------------------------------------------------------------------- 1 | # Release task for PbLib projects 2 | # See https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema for reference 3 | 4 | pool: 5 | vmImage: windows-latest 6 | demands: Cmd 7 | 8 | trigger: 9 | branches: 10 | include: 11 | - refs/tags/* 12 | 13 | variables: 14 | - group: signingSecrets #create this group with SECRET variables `signingUsername` and `signingPassword` 15 | - group: nugetKeys #create this group with SECRET variables `nugetKey` 16 | - name: githubConnectionName 17 | value: yourConnection #replace this 18 | - name: projectName 19 | value: yourProjectName #replace this 20 | - name: githubRepositoryName 21 | value: yourOrganization/yourRepo #replace this 22 | 23 | steps: 24 | - task: BatchScript@1 25 | displayName: 'FAKE Build' 26 | inputs: 27 | filename: build.cmd 28 | arguments: 'All SignClientUser=$(signingUsername) SignClientSecret=$(signingPassword) nugetpublishurl=https://www.nuget.org/api/v2/package nugetkey=$(nugetKey)' 29 | 30 | - task: GitHubRelease@0 31 | displayName: 'GitHub release (create)' 32 | inputs: 33 | gitHubConnection: $(githubConnectionName) 34 | repositoryName: $(githubRepositoryName) 35 | title: '$(projectName) v$(Build.SourceBranchName)' 36 | releaseNotesFile: 'RELEASE_NOTES.md' 37 | assets: | 38 | bin\nuget\*.nupkg -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | PowerShell.exe -file "build.ps1" %* -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ########################################################################## 3 | # This is the Fake bootstrapper script for Linux and OS X. 4 | ########################################################################## 5 | 6 | # Define directories. 7 | SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 8 | TOOLS_DIR=$SCRIPT_DIR/tools 9 | SIGNCLIENT_DIR=$TOOLS_DIR/signclient 10 | NUGET_EXE=$TOOLS_DIR/nuget.exe 11 | NUGET_URL=https://dist.nuget.org/win-x86-commandline/v4.0.0/nuget.exe 12 | FAKE_VERSION=4.61.2 13 | FAKE_EXE=$TOOLS_DIR/FAKE/tools/FAKE.exe 14 | DOTNET_VERSION=2.1.500 15 | DOTNET_INSTALLER_URL=https://raw.githubusercontent.com/dotnet/cli/v$DOTNET_VERSION/scripts/obtain/dotnet-install.sh 16 | DOTNET_CHANNEL=LTS; 17 | DOCFX_VERSION=2.40.5 18 | DOCFX_EXE=$TOOLS_DIR/docfx.console/tools/docfx.exe 19 | 20 | # Define default arguments. 21 | TARGET="Default" 22 | CONFIGURATION="Release" 23 | VERBOSITY="verbose" 24 | DRYRUN= 25 | SCRIPT_ARGUMENTS=() 26 | 27 | # Parse arguments. 28 | for i in "$@"; do 29 | case $1 in 30 | -t|--target) TARGET="$2"; shift ;; 31 | -c|--configuration) CONFIGURATION="$2"; shift ;; 32 | -v|--verbosity) VERBOSITY="$2"; shift ;; 33 | -d|--dryrun) DRYRUN="-dryrun" ;; 34 | --) shift; SCRIPT_ARGUMENTS+=("$@"); break ;; 35 | *) SCRIPT_ARGUMENTS+=("$1") ;; 36 | esac 37 | shift 38 | done 39 | 40 | # Make sure the tools folder exist. 41 | if [ ! -d "$TOOLS_DIR" ]; then 42 | mkdir "$TOOLS_DIR" 43 | fi 44 | 45 | ########################################################################### 46 | # INSTALL .NET CORE CLI 47 | ########################################################################### 48 | 49 | echo "Installing .NET CLI..." 50 | if [ ! -d "$SCRIPT_DIR/.dotnet" ]; then 51 | mkdir "$SCRIPT_DIR/.dotnet" 52 | fi 53 | curl -Lsfo "$SCRIPT_DIR/.dotnet/dotnet-install.sh" $DOTNET_INSTALLER_URL 54 | bash "$SCRIPT_DIR/.dotnet/dotnet-install.sh" --version $DOTNET_VERSION --channel $DOTNET_CHANNEL --install-dir .dotnet --no-path 55 | export PATH="$SCRIPT_DIR/.dotnet":$PATH 56 | export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 57 | export DOTNET_CLI_TELEMETRY_OPTOUT=1 58 | chmod -R 0755 ".dotnet" 59 | "$SCRIPT_DIR/.dotnet/dotnet" --info 60 | 61 | 62 | ########################################################################### 63 | # INSTALL NUGET 64 | ########################################################################### 65 | 66 | # Download NuGet if it does not exist. 67 | if [ ! -f "$NUGET_EXE" ]; then 68 | echo "Downloading NuGet..." 69 | curl -Lsfo "$NUGET_EXE" $NUGET_URL 70 | if [ $? -ne 0 ]; then 71 | echo "An error occured while downloading nuget.exe." 72 | exit 1 73 | fi 74 | fi 75 | 76 | ########################################################################### 77 | # INSTALL FAKE 78 | ########################################################################### 79 | 80 | if [ ! -f "$FAKE_EXE" ]; then 81 | mono "$NUGET_EXE" install Fake -ExcludeVersion -Version $FAKE_VERSION -OutputDirectory "$TOOLS_DIR" 82 | if [ $? -ne 0 ]; then 83 | echo "An error occured while installing Cake." 84 | exit 1 85 | fi 86 | fi 87 | 88 | # Make sure that Fake has been installed. 89 | if [ ! -f "$FAKE_EXE" ]; then 90 | echo "Could not find Fake.exe at '$FAKE_EXE'." 91 | exit 1 92 | fi 93 | 94 | ########################################################################### 95 | # INSTALL DOCFX 96 | ########################################################################### 97 | if [ ! -f "$DOCFX_EXE" ]; then 98 | mono "$NUGET_EXE" install docfx.console -ExcludeVersion -Version $DOCFX_VERSION -OutputDirectory "$TOOLS_DIR" 99 | if [ $? -ne 0 ]; then 100 | echo "An error occured while installing DocFx." 101 | exit 1 102 | fi 103 | fi 104 | 105 | # Make sure that DocFx has been installed. 106 | if [ ! -f "$DOCFX_EXE" ]; then 107 | echo "Could not find docfx.exe at '$DOCFX_EXE'." 108 | exit 1 109 | fi 110 | 111 | ########################################################################### 112 | # INSTALL SignTool 113 | ########################################################################### 114 | if [ ! -f "$SIGNTOOL_EXE" ]; then 115 | "$SCRIPT_DIR/.dotnet/dotnet" tool install SignClient --version 1.0.82 --tool-path "$SIGNCLIENT_DIR" 116 | if [ $? -ne 0 ]; then 117 | echo "SignClient already installed." 118 | fi 119 | fi 120 | 121 | 122 | ########################################################################### 123 | # WORKAROUND FOR MONO 124 | ########################################################################### 125 | export FrameworkPathOverride=/usr/lib/mono/4.5/ 126 | 127 | ########################################################################### 128 | # RUN BUILD SCRIPT 129 | ########################################################################### 130 | 131 | # Start Fake 132 | exec mono "$FAKE_EXE" build.fsx "${SCRIPT_ARGUMENTS[@]}" --verbosity=$VERBOSITY --configuration=$CONFIGURATION --target=$TARGET $DRYRUN 133 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | sql: 5 | image: mcr.microsoft.com/mssql/server:2019-latest 6 | hostname: sql 7 | restart: always 8 | ports: 9 | - '1633:1433' 10 | environment: 11 | ACCEPT_EULA: "Y" 12 | MSSQL_SA_PASSWORD: "This!IsOpenSource1" 13 | 14 | lighthouse: 15 | image: petabridge/lighthouse:latest 16 | hostname: lighthouse 17 | ports: 18 | - '9110:9110' 19 | - '4053:4053' 20 | environment: 21 | ACTORSYSTEM: "AkkaTrader" 22 | CLUSTER_PORT: 4053 23 | CLUSTER_IP: "lighthouse" 24 | CLUSTER_SEEDS: "akka.tcp://AkkaTrader@lighthouse:4053" 25 | 26 | tradeprocessor: 27 | image: akka.cqrs.tradeprocessor 28 | ports: 29 | - '0:9110' 30 | environment: 31 | CLUSTER_SEEDS: "akka.tcp://AkkaTrader@lighthouse:4053" 32 | CLUSTER_PORT: 5110 33 | SQL_CONNECTION_STR: "Server=sql,1433;User Id=sa;Password=This!IsOpenSource1;TrustServerCertificate=true" 34 | SQL_PROVIDER_NAME: "SqlServer.2019" 35 | restart: on-failure 36 | depends_on: 37 | - "sql" 38 | - "lighthouse" 39 | 40 | traders: 41 | image: akka.cqrs.traders 42 | ports: 43 | - '0:9110' 44 | environment: 45 | CLUSTER_SEEDS: "akka.tcp://AkkaTrader@lighthouse:4053" 46 | CLUSTER_PORT: 5110 47 | restart: on-failure 48 | depends_on: 49 | - "lighthouse" 50 | 51 | lighthouse_pricing: 52 | image: petabridge/lighthouse:latest 53 | hostname: lighthouse_pricing 54 | ports: 55 | - '9111:9110' 56 | - '4054:4054' 57 | environment: 58 | ACTORSYSTEM: "AkkaPricing" 59 | CLUSTER_PORT: 4054 60 | CLUSTER_IP: "lighthouse_pricing" 61 | CLUSTER_SEEDS: "akka.tcp://AkkaPricing@lighthouse_pricing:4054" 62 | 63 | pricing-engine: 64 | image: akka.cqrs.pricing 65 | deploy: 66 | replicas: 3 67 | ports: 68 | - '0:9110' 69 | environment: 70 | CLUSTER_SEEDS: "akka.tcp://AkkaPricing@lighthouse_pricing:4054" 71 | CLUSTER_PORT: 6055 72 | SQL_CONNECTION_STR: "Server=sql,1433;User Id=sa;Password=This!IsOpenSource1;TrustServerCertificate=true" 73 | SQL_PROVIDER_NAME: "SqlServer.2019" 74 | restart: on-failure 75 | depends_on: 76 | - "sql" 77 | - "lighthouse_pricing" -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API Docs -------------------------------------------------------------------------------- /docs/articles/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Article text goes here. -------------------------------------------------------------------------------- /docs/articles/toc.yml: -------------------------------------------------------------------------------- 1 | - name: Introduction 2 | href: index.md -------------------------------------------------------------------------------- /docs/docfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": [ 3 | { 4 | "src": [ 5 | { 6 | "files": [ "**/*.csproj" ], 7 | "exclude": [ 8 | "**/obj/**", 9 | "**/bin/**", 10 | "_site/**", 11 | "**/*Tests*.csproj", 12 | "**/*Tests.*.csproj" 13 | ], 14 | "src": "../src" 15 | } 16 | ], 17 | "dest": "api" 18 | } 19 | ], 20 | "build": { 21 | "content": [ 22 | { 23 | "files": [ 24 | "api/**.yml", 25 | "api/index.md" 26 | ] 27 | }, 28 | { 29 | "files": [ 30 | "articles/**.md", 31 | "articles/**/toc.yml", 32 | "toc.yml", 33 | "*.md" 34 | ], 35 | "exclude": [ 36 | "obj/**", 37 | "_site/**" 38 | ] 39 | }, 40 | ], 41 | "resource": [ 42 | { 43 | "files": [ 44 | "images/**", 45 | "web.config", 46 | ], 47 | "exclude": [ 48 | "obj/**", 49 | "_site/**" 50 | ] 51 | } 52 | ], 53 | "sitemap": { 54 | "baseUrl": "https://yoursite.github.io/" 55 | }, 56 | "dest": "_site", 57 | "globalMetadata": { 58 | "_appTitle": "Akka.CQRS", 59 | "_disableContribution": "true", 60 | "_appLogoPath": "/images/icon.png", 61 | }, 62 | "globalMetadataFiles": [], 63 | "fileMetadataFiles": [], 64 | "template": [ 65 | "default", 66 | "template" 67 | ], 68 | "postProcessors": ["ExtractSearchIndex"], 69 | "noLangKeyword": false 70 | } 71 | } -------------------------------------------------------------------------------- /docs/images/akka-cqrs-architectural-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaronontheweb/InMemoryCQRSReplication/5ffdee8533876c09b4f70ba6c5425d95f102f186/docs/images/akka-cqrs-architectural-overview.png -------------------------------------------------------------------------------- /docs/images/akka-cqrs-inmemory-replication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaronontheweb/InMemoryCQRSReplication/5ffdee8533876c09b4f70ba6c5425d95f102f186/docs/images/akka-cqrs-inmemory-replication.png -------------------------------------------------------------------------------- /docs/images/docker-for-windows-networking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaronontheweb/InMemoryCQRSReplication/5ffdee8533876c09b4f70ba6c5425d95f102f186/docs/images/docker-for-windows-networking.png -------------------------------------------------------------------------------- /docs/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaronontheweb/InMemoryCQRSReplication/5ffdee8533876c09b4f70ba6c5425d95f102f186/docs/images/icon.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction to My Project -------------------------------------------------------------------------------- /docs/toc.yml: -------------------------------------------------------------------------------- 1 | - name: Home 2 | href: index.md 3 | - name: Documentation 4 | href: articles/ 5 | - name: API Reference 6 | href: api/ -------------------------------------------------------------------------------- /docs/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "rollForward": "latestMinor", 4 | "version": "7.0" 5 | } 6 | } -------------------------------------------------------------------------------- /serve-docs.cmd: -------------------------------------------------------------------------------- 1 | PowerShell.exe -file "serve-docs.ps1" %* .\docs\docfx.json --serve -p 8090 -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure.Tests/Akka.CQRS.Infrastructure.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetVersion) 4 | 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure.Tests/ConfigSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.CQRS.Infrastructure.Ops; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace Akka.CQRS.Infrastructure.Tests 7 | { 8 | public class ConfigSpecs 9 | { 10 | [Fact] 11 | public void ShouldLoadOpsConfig() 12 | { 13 | var config = OpsConfig.GetOpsConfig(); 14 | config.GetConfig("akka.cluster").HasPath("split-brain-resolver.active-strategy").Should().BeTrue(); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/Akka.CQRS.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | netstandard2.0 4 | Shared, non-domain-specific infrastructure used by various Akka.NET services. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/Ops/OpsConfig.cs: -------------------------------------------------------------------------------- 1 | using Akka.Configuration; 2 | 3 | namespace Akka.CQRS.Infrastructure.Ops 4 | { 5 | /// 6 | /// Helper class for ensuring that certain parts of Akka.NET's 7 | /// configuration are applied consistently across all services. 8 | /// 9 | public class OpsConfig 10 | { 11 | public static Akka.Configuration.Config GetOpsConfig() 12 | { 13 | return ConfigurationFactory.FromResource("Akka.CQRS.Infrastructure.Ops.ops.conf"); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/Ops/ops.conf: -------------------------------------------------------------------------------- 1 | # Akka.Cluster split-brain resolver configurations 2 | akka.cluster{ 3 | downing-provider-class = "Akka.Cluster.SplitBrainResolver, Akka.Cluster" 4 | split-brain-resolver { 5 | active-strategy = keep-majority 6 | } 7 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/SqlDbHoconHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Configuration; 3 | 4 | namespace Akka.CQRS.Infrastructure 5 | { 6 | /// 7 | /// Shared utility class for formatting SQL connection strings into the required 8 | /// Akka.Persistence.Sql HOCON . 9 | /// 10 | public static class SqlDbHoconHelper 11 | { 12 | public static Configuration.Config GetSqlHocon(string connectionStr, string providerName) 13 | { 14 | var hocon = $@" 15 | akka.persistence.journal.sql {{ 16 | connection-string = ""{connectionStr}"" 17 | provider-name = ""{providerName}"" 18 | }} 19 | akka.persistence.query.journal.sql {{ 20 | connection-string = ""{connectionStr}"" 21 | provider-name = ""{providerName}"" 22 | }} 23 | akka.persistence.snapshot-store.sql{{ 24 | connection-string = ""{connectionStr}"" 25 | provider-name = ""{providerName}"" 26 | }}"; 27 | return hocon; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/StockEventTagger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Text; 5 | using Akka.CQRS.Events; 6 | using Akka.Persistence.Journal; 7 | 8 | namespace Akka.CQRS.Infrastructure 9 | { 10 | /// 11 | /// Used to tag trade events so they can be consumed inside Akka.Persistence.Query 12 | /// 13 | public sealed class StockEventTagger : IWriteEventAdapter 14 | { 15 | public string Manifest(object evt) 16 | { 17 | return string.Empty; 18 | } 19 | 20 | public object ToJournal(object evt) 21 | { 22 | switch (evt) 23 | { 24 | case Ask ask: 25 | return new Tagged(evt, ImmutableHashSet.Empty.Add(ask.StockId).Add("Ask")); 26 | case Bid bid: 27 | return new Tagged(evt, ImmutableHashSet.Empty.Add(bid.StockId).Add("Bid")); 28 | case Fill fill: 29 | return new Tagged(evt, ImmutableHashSet.Empty.Add(fill.StockId).Add("Fill")); 30 | case Match match: 31 | return new Tagged(evt, ImmutableHashSet.Empty.Add(match.StockId).Add("Match")); 32 | default: 33 | return evt; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Infrastructure/StockShardMsgRouter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Cluster.Sharding; 3 | using Akka.CQRS.Events; 4 | using Akka.Persistence.Extras; 5 | 6 | namespace Akka.CQRS.Infrastructure 7 | { 8 | /// 9 | /// Used to route sharding messages to order book actors hosted via Akka.Cluster.Sharding. 10 | /// 11 | public sealed class StockShardMsgRouter : HashCodeMessageExtractor 12 | { 13 | /// 14 | /// 3 nodes hosting order books, 10 shards per node. 15 | /// 16 | public const int DefaultShardCount = 30; 17 | 18 | public StockShardMsgRouter() : this(DefaultShardCount) 19 | { 20 | } 21 | 22 | public StockShardMsgRouter(int maxNumberOfShards) : base(maxNumberOfShards) 23 | { 24 | } 25 | 26 | public override string EntityId(object message) 27 | { 28 | if (message is IWithStockId stockMsg) 29 | { 30 | return stockMsg.StockId; 31 | } 32 | 33 | switch (message) 34 | { 35 | case ConfirmableMessage a: 36 | return a.Message.StockId; 37 | case ConfirmableMessage b: 38 | return b.Message.StockId; 39 | case ConfirmableMessage f: 40 | return f.Message.StockId; 41 | case ConfirmableMessage m: 42 | return m.Message.StockId; 43 | } 44 | 45 | return null; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Matching.Tests/Akka.CQRS.Matching.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0;$(NetVersion) 4 | 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Matching.Tests/MatchingEngineSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Akka.CQRS.Events; 6 | using FluentAssertions; 7 | using Xunit; 8 | 9 | namespace Akka.CQRS.Matching.Tests 10 | { 11 | public class MatchingEngineSpecs 12 | { 13 | public const string TickerSymbol = "PTB"; 14 | private readonly MatchingEngine _matchingEngine = new MatchingEngine(TickerSymbol); 15 | 16 | [Fact(DisplayName = "MatchingEngine should generate correct events for ask then bid")] 17 | public void MatchingEngine_should_generate_correct_order_events_for_ask_then_bid() 18 | { 19 | var ask = new Ask(TickerSymbol, "foo", 10.0m, 4.0, DateTimeOffset.Now); 20 | 21 | // bid will be slightly higher than ask - settlement price should be bid price 22 | var bid = new Bid(TickerSymbol, "bar", 11.0m, 4.0, DateTimeOffset.Now); 23 | 24 | var askEvents = _matchingEngine.WithAsk(ask); 25 | askEvents.Should().BeEmpty(); 26 | _matchingEngine.AskTrades.Count.Should().Be(1); 27 | 28 | var bidEvents = _matchingEngine.WithBid(bid).ToList(); 29 | 30 | // validate the correct number of outputs and remaining orders first 31 | bidEvents.Should().NotBeEmpty(); 32 | bidEvents.Count.Should().Be(3); 33 | 34 | _matchingEngine.AskTrades.Count.Should().Be(0); 35 | _matchingEngine.AsksByPrice.Count.Should().Be(0); 36 | _matchingEngine.BidTrades.Count.Should().Be(0); 37 | _matchingEngine.BidsByPrice.Count.Should().Be(0); 38 | 39 | // all orders should have been completely filled 40 | var fills = bidEvents.Where(x => x is Fill).Cast().ToList(); 41 | fills[0].Partial.Should().BeFalse(); 42 | fills[1].Partial.Should().BeFalse(); 43 | 44 | // filled price should be the bid price 45 | fills[0].Price.Should().Be(bid.BidPrice); 46 | 47 | // match information should reflect the same 48 | var match = (Match)bidEvents.Single(x => x is Match); 49 | match.BuyOrderId.Should().Be(bid.OrderId); 50 | match.SellOrderId.Should().Be(ask.OrderId); 51 | match.StockId.Should().Be(TickerSymbol); 52 | match.SettlementPrice.Should().Be(bid.BidPrice); 53 | match.Quantity.Should().Be(bid.BidQuantity); 54 | } 55 | 56 | [Fact(DisplayName = "MatchingEngine should generate correct events for bid then ask")] 57 | public void MatchingEngine_should_generate_correct_order_events_for_bid_then_ask() 58 | { 59 | var ask = new Ask(TickerSymbol, "foo", 10.0m, 4.0, DateTimeOffset.Now); 60 | 61 | // bid will be slightly higher than ask - settlement price should be bid price 62 | var bid = new Bid(TickerSymbol, "bar", 11.0m, 4.0, DateTimeOffset.Now); 63 | 64 | var bidEvents = _matchingEngine.WithBid(bid); 65 | bidEvents.Should().BeEmpty(); 66 | _matchingEngine.BidTrades.Count.Should().Be(1); 67 | 68 | var askEvents = _matchingEngine.WithAsk(ask).ToList(); 69 | 70 | // validate the correct number of outputs and remaining orders first 71 | askEvents.Should().NotBeEmpty(); 72 | askEvents.Count.Should().Be(3); 73 | 74 | _matchingEngine.AskTrades.Count.Should().Be(0); 75 | _matchingEngine.AsksByPrice.Count.Should().Be(0); 76 | _matchingEngine.BidTrades.Count.Should().Be(0); 77 | _matchingEngine.BidsByPrice.Count.Should().Be(0); 78 | 79 | // all orders should have been completely filled 80 | var fills = askEvents.Where(x => x is Fill).Cast().ToList(); 81 | fills[0].Partial.Should().BeFalse(); 82 | fills[1].Partial.Should().BeFalse(); 83 | 84 | // filled price should be the bid price 85 | fills[0].Price.Should().Be(bid.BidPrice); 86 | 87 | // match information should reflect the same 88 | var match = (Match)askEvents.Single(x => x is Match); 89 | match.BuyOrderId.Should().Be(bid.OrderId); 90 | match.SellOrderId.Should().Be(ask.OrderId); 91 | match.StockId.Should().Be(TickerSymbol); 92 | match.SettlementPrice.Should().Be(bid.BidPrice); 93 | match.Quantity.Should().Be(bid.BidQuantity); 94 | } 95 | 96 | [Fact(DisplayName = "Should create valid OrderbookSnapshot and recreate matching engine from snapshot")] 97 | public void ShouldCreateAndRecreateFromOrderbookSnapshot() 98 | { 99 | var ask = new Ask(TickerSymbol, "foo", 12.0m, 5.0, DateTimeOffset.Now); 100 | 101 | // bid is lower than ask - no trades 102 | var bid = new Bid(TickerSymbol, "bar", 11.0m, 4.0, DateTimeOffset.Now); 103 | 104 | var bidEvents = _matchingEngine.WithBid(bid); 105 | var askEvents = _matchingEngine.WithAsk(ask); 106 | askEvents.Should().BeEmpty(); 107 | bidEvents.Should().BeEmpty(); 108 | 109 | _matchingEngine.AskTrades.Count.Should().Be(1); 110 | _matchingEngine.BidTrades.Count.Should().Be(1); 111 | 112 | // create a snapshot 113 | var snapshot = _matchingEngine.GetSnapshot(); 114 | 115 | // add a second ask to matching engine (need to verify immutability) 116 | // still higher than original bid - won't trigger any trades 117 | var ask2 = new Ask(TickerSymbol, "fuber", 13.0m, 5.0, DateTimeOffset.Now); 118 | var ask2Events = _matchingEngine.WithAsk(ask2); 119 | ask2Events.Should().BeEmpty(); 120 | 121 | snapshot.Asks.Count.Should().Be(1); 122 | snapshot.Bids.Count.Should().Be(1); 123 | snapshot.StockId.Should().Be(_matchingEngine.StockId); 124 | _matchingEngine.AskTrades.Count.Should().Be(2); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Matching/Akka.CQRS.Matching.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(NetStandardVersion) 5 | Matching engine logic. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/Akka.CQRS.Pricing.Actors.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetStandardVersion) 4 | Pricing analytics actors. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/MatchAggregator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Akka.Actor; 4 | using Akka.Cluster.Tools.PublishSubscribe; 5 | using Akka.CQRS.Events; 6 | using Akka.CQRS.Pricing.Commands; 7 | using Akka.CQRS.Pricing.Events; 8 | using Akka.CQRS.Pricing.Views; 9 | using Akka.CQRS.Util; 10 | using Akka.Event; 11 | using Akka.Persistence; 12 | using Akka.Persistence.Query; 13 | using Akka.Streams; 14 | using Akka.Streams.Dsl; 15 | using Petabridge.Collections; 16 | 17 | namespace Akka.CQRS.Pricing.Actors 18 | { 19 | /// 20 | /// Used to aggregate events via Akka.Persistence.Query 21 | /// 22 | public class MatchAggregator : ReceivePersistentActor 23 | { 24 | // Take a snapshot every 10 journal entries 25 | public const int SnapshotEveryN = 10; 26 | 27 | private readonly IEventsByTagQuery _eventsByTag; 28 | private MatchAggregate _matchAggregate; 29 | private readonly IActorRef _mediator; 30 | private readonly ITimestamper _timestamper; 31 | private readonly ILoggingAdapter _log = Context.GetLogger(); 32 | private CircularBuffer _priceUpdates = new CircularBuffer(MatchAggregate.DefaultSampleSize); 33 | private CircularBuffer _volumeUpdates = new CircularBuffer(MatchAggregate.DefaultSampleSize); 34 | private ICancelable _publishPricesTask; 35 | 36 | private readonly string _priceTopic; 37 | private readonly string _volumeTopic; 38 | 39 | public readonly string TickerSymbol; 40 | public override string PersistenceId { get; } 41 | 42 | public long QueryOffset { get; private set; } 43 | 44 | private class PublishEvents 45 | { 46 | public static readonly PublishEvents Instance = new PublishEvents(); 47 | private PublishEvents() { } 48 | } 49 | 50 | public MatchAggregator(string tickerSymbol, IEventsByTagQuery eventsByTag) 51 | : this(tickerSymbol, eventsByTag, DistributedPubSub.Get(Context.System).Mediator, CurrentUtcTimestamper.Instance) 52 | { 53 | } 54 | 55 | public MatchAggregator(string tickerSymbol, IEventsByTagQuery eventsByTag, IActorRef mediator, ITimestamper timestamper) 56 | { 57 | TickerSymbol = tickerSymbol; 58 | _priceTopic = PriceTopicHelpers.PriceUpdateTopic(TickerSymbol); 59 | _volumeTopic = PriceTopicHelpers.VolumeUpdateTopic(TickerSymbol); 60 | _eventsByTag = eventsByTag; 61 | _mediator = mediator; 62 | _timestamper = timestamper; 63 | PersistenceId = EntityIdHelper.IdForPricing(tickerSymbol); 64 | 65 | Receives(); 66 | Commands(); 67 | } 68 | 69 | private void Receives() 70 | { 71 | /* 72 | * Can be saved as a snapshot or as an event 73 | */ 74 | Recover(o => 75 | { 76 | if (o.Snapshot is MatchAggregatorSnapshot s) 77 | { 78 | RecoverAggregateData(s); 79 | } 80 | }); 81 | 82 | Recover(s => { RecoverAggregateData(s); }); 83 | } 84 | 85 | /// 86 | /// Recovery has completed successfully. 87 | /// 88 | protected override void OnReplaySuccess() 89 | { 90 | var mat = Context.Materializer(); 91 | var self = Self; 92 | 93 | // transmit all tag events to myself 94 | _eventsByTag.EventsByTag(TickerSymbol, Offset.Sequence(QueryOffset)) 95 | .Where(x => x.Event is Match) // only care about Match events 96 | .RunWith(Sink.ActorRef(self, UnexpectedEndOfStream.Instance), mat); 97 | 98 | _publishPricesTask = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromSeconds(10), 99 | TimeSpan.FromSeconds(10), Self, PublishEvents.Instance, ActorRefs.NoSender); 100 | 101 | base.OnReplaySuccess(); 102 | } 103 | 104 | private void RecoverAggregateData(MatchAggregatorSnapshot s) 105 | { 106 | _matchAggregate = new MatchAggregate(TickerSymbol, s.AvgPrice, s.AvgVolume); 107 | _priceUpdates.Enqueue(s.RecentPriceUpdates.ToArray()); 108 | _volumeUpdates.Enqueue(s.RecentVolumeUpdates.ToArray()); 109 | QueryOffset = s.QueryOffset; 110 | } 111 | 112 | private MatchAggregatorSnapshot SaveAggregateData() 113 | { 114 | return new MatchAggregatorSnapshot(QueryOffset, _matchAggregate.AvgPrice.CurrentAvg, _matchAggregate.AvgVolume.CurrentAvg, _priceUpdates.ToList(), _volumeUpdates.ToList()); 115 | } 116 | 117 | private void Commands() 118 | { 119 | Command(e => 120 | { 121 | if (e.Event is Match m) 122 | { 123 | // update the offset 124 | if (e.Offset is Sequence s) 125 | { 126 | QueryOffset = s.Value; 127 | } 128 | 129 | if (_matchAggregate == null) 130 | { 131 | _matchAggregate = new MatchAggregate(TickerSymbol, m.SettlementPrice, m.Quantity); 132 | return; 133 | } 134 | 135 | if (!_matchAggregate.WithMatch(m)) 136 | { 137 | _log.Warning("Received Match for ticker symbol [{0}] - but we only accept symbols for [{1}]", m.StockId, TickerSymbol); 138 | } 139 | } 140 | }); 141 | 142 | // Command sent by a PriceViewActor to pull down a complete snapshot of active pricing data 143 | Command(f => 144 | { 145 | // no price data yet 146 | if (_priceUpdates.Count == 0 || _volumeUpdates.Count == 0) 147 | { 148 | Sender.Tell(PriceAndVolumeSnapshot.Empty(TickerSymbol)); 149 | } 150 | else 151 | { 152 | Sender.Tell(new PriceAndVolumeSnapshot(TickerSymbol, _priceUpdates.ToArray(), _volumeUpdates.ToArray())); 153 | } 154 | 155 | }); 156 | 157 | Command(p => 158 | { 159 | if (_matchAggregate == null) 160 | return; 161 | 162 | var (latestPrice, latestVolume) = _matchAggregate.FetchMetrics(_timestamper); 163 | 164 | // Need to update pricing records prior to persisting our state, since this data is included in 165 | // output of SaveAggregateData() 166 | _priceUpdates.Add(latestPrice); 167 | _volumeUpdates.Add(latestVolume); 168 | 169 | PersistAsync(SaveAggregateData(), snapshot => 170 | { 171 | _log.Info("Saved latest price {0} and volume {1}", snapshot.AvgPrice, snapshot.AvgVolume); 172 | if (LastSequenceNr % SnapshotEveryN == 0) 173 | { 174 | SaveSnapshot(snapshot); 175 | } 176 | }); 177 | 178 | // publish updates to in-memory replicas 179 | _mediator.Tell(new Publish(_priceTopic, latestPrice)); 180 | _mediator.Tell(new Publish(_volumeTopic, latestVolume)); 181 | }); 182 | 183 | Command(p => 184 | { 185 | if (_log.IsDebugEnabled) 186 | { 187 | _log.Debug("pinged via {0}", Sender); 188 | } 189 | }); 190 | 191 | Command(s => 192 | { 193 | // clean-up prior snapshots and journal events 194 | DeleteSnapshots(new SnapshotSelectionCriteria(s.Metadata.SequenceNr-1)); 195 | DeleteMessages(s.Metadata.SequenceNr); 196 | }); 197 | } 198 | 199 | protected override void PostStop() 200 | { 201 | _publishPricesTask?.Cancel(); 202 | base.PostStop(); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/PriceInitiatorActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Akka.Actor; 5 | using Akka.CQRS.Pricing.Commands; 6 | using Akka.Event; 7 | using Akka.Persistence; 8 | using Akka.Persistence.Query; 9 | using Akka.Streams; 10 | using Akka.Streams.Dsl; 11 | 12 | namespace Akka.CQRS.Pricing.Actors 13 | { 14 | /// 15 | /// Intended to be a Cluster Singleton. Responsible for ensuring there's at least one instance 16 | /// of a for every single persistence id found inside the datastore. 17 | /// 18 | public sealed class PriceInitiatorActor : ReceiveActor 19 | { 20 | private readonly ILoggingAdapter _log = Context.GetLogger(); 21 | private readonly IPersistenceIdsQuery _tradeIdsQuery; 22 | private readonly IActorRef _pricingQueryProxy; 23 | private readonly HashSet _tickers = new HashSet(); 24 | 25 | /* 26 | * Used to periodically ping Akka.Cluster.Sharding and ensure that all pricing 27 | * entities are up and producing events for their in-memory replicas over the network. 28 | * 29 | * Technically, akka.cluster.sharding.remember-entities = on should take care of this 30 | * for us in the initial pass, but the impact of having this code is virtually zero 31 | * and in the event of a network partition or an error somewhere, will effectively prod 32 | * the non-existent entity into action. Worth having it. 33 | */ 34 | private ICancelable _heartbeatInterval; 35 | 36 | 37 | private class Heartbeat 38 | { 39 | public static readonly Heartbeat Instance = new Heartbeat(); 40 | private Heartbeat() { } 41 | } 42 | 43 | public PriceInitiatorActor(IPersistenceIdsQuery tradeIdsQuery, IActorRef pricingQueryProxy) 44 | { 45 | _tradeIdsQuery = tradeIdsQuery; 46 | _pricingQueryProxy = pricingQueryProxy; 47 | 48 | Receive(p => 49 | { 50 | _tickers.Add(p.StockId); 51 | _pricingQueryProxy.Tell(p); 52 | }); 53 | 54 | Receive(h => 55 | { 56 | foreach (var p in _tickers) 57 | { 58 | _pricingQueryProxy.Tell(new Ping(p)); 59 | } 60 | }); 61 | 62 | Receive(end => 63 | { 64 | _log.Warning("Received unexpected end of PersistenceIds stream. Restarting."); 65 | throw new ApplicationException("Restart me!"); 66 | }); 67 | } 68 | 69 | protected override void PreStart() 70 | { 71 | var mat = Context.Materializer(); 72 | var self = Self; 73 | _tradeIdsQuery.PersistenceIds() 74 | .Where(x => x.EndsWith(EntityIdHelper 75 | .OrderBookSuffix)) // skip persistence ids belonging to price entities 76 | .Select(x => new Ping(EntityIdHelper.ExtractTickerFromPersistenceId(x))) 77 | .RunWith(Sink.ActorRef(self, UnexpectedEndOfStream.Instance), mat); 78 | 79 | _heartbeatInterval = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromSeconds(30), 80 | TimeSpan.FromSeconds(30), Self, Heartbeat.Instance, ActorRefs.NoSender); 81 | } 82 | 83 | protected override void PostStop() 84 | { 85 | _heartbeatInterval?.Cancel(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/PriceViewActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Text; 5 | using Akka.Actor; 6 | using Akka.Cluster.Tools.PublishSubscribe; 7 | using Akka.CQRS.Pricing.Commands; 8 | using Akka.CQRS.Pricing.Events; 9 | using Akka.CQRS.Pricing.Views; 10 | using Akka.Event; 11 | 12 | namespace Akka.CQRS.Pricing.Actors 13 | { 14 | /// 15 | /// In-memory, replicated view of the current price and volume for a specific stock. 16 | /// 17 | public sealed class PriceVolumeViewActor : ReceiveActor, IWithUnboundedStash 18 | { 19 | private readonly string _tickerSymbol; 20 | private ICancelable _pruneTimer; 21 | private readonly ILoggingAdapter _log = Context.GetLogger(); 22 | 23 | // the Cluster.Sharding proxy 24 | private readonly IActorRef _priceActorGateway; 25 | 26 | // the DistributedPubSub mediator 27 | private readonly IActorRef _mediator; 28 | 29 | private IActorRef _tickerEntity; 30 | private PriceHistory _history; 31 | private readonly string _priceTopic; 32 | 33 | private sealed class Prune 34 | { 35 | public static readonly Prune Instance = new Prune(); 36 | private Prune() { } 37 | } 38 | 39 | public PriceVolumeViewActor(string tickerSymbol, IActorRef priceActorGateway, IActorRef mediator) 40 | { 41 | _tickerSymbol = tickerSymbol; 42 | _priceActorGateway = priceActorGateway; 43 | _priceTopic = PriceTopicHelpers.PriceUpdateTopic(_tickerSymbol); 44 | _mediator = mediator; 45 | _history = new PriceHistory(_tickerSymbol, ImmutableSortedSet.Empty); 46 | 47 | WaitingForPriceAndVolume(); 48 | } 49 | 50 | public IStash Stash { get; set; } 51 | 52 | private void WaitingForPriceAndVolume() 53 | { 54 | Receive(s => 55 | { 56 | if (s.PriceUpdates.Length == 0) // empty set - no price data yet 57 | { 58 | _history = new PriceHistory(_tickerSymbol, ImmutableSortedSet.Empty); 59 | _log.Info("Received empty price history for [{0}]", _history.StockId); 60 | } 61 | else 62 | { 63 | _history = new PriceHistory(_tickerSymbol, s.PriceUpdates.ToImmutableSortedSet()); 64 | _log.Info("Received recent price history for [{0}] - current price is [{1}] as of [{2}]", _history.StockId, _history.CurrentPrice, _history.Until); 65 | } 66 | 67 | _tickerEntity = Sender; 68 | _mediator.Tell(new Subscribe(_priceTopic, Self)); 69 | }); 70 | 71 | Receive(ack => 72 | { 73 | _log.Info("Subscribed to {0} - ready for real-time processing.", _priceTopic); 74 | Become(Processing); 75 | Context.Watch(_tickerEntity); 76 | Context.SetReceiveTimeout(null); 77 | }); 78 | 79 | Receive(_ => 80 | { 81 | _log.Warning("Received no initial price values for [{0}] from source of truth after 5s. Retrying..", _tickerSymbol); 82 | _priceActorGateway.Tell(new FetchPriceAndVolume(_tickerSymbol)); 83 | }); 84 | } 85 | 86 | private void Processing() 87 | { 88 | Receive(p => 89 | { 90 | _history = _history.WithPrice(p); 91 | _log.Info("[{0}] - current price is [{1}] as of [{2}]", _history.StockId, p.CurrentAvgPrice, p.Timestamp); 92 | 93 | }); 94 | 95 | Receive(h => 96 | { 97 | Sender.Tell(_history); 98 | }); 99 | 100 | Receive(_ => 101 | { 102 | Sender.Tell(_history.CurrentPriceUpdate); 103 | }); 104 | 105 | Receive(_ => { }); // ignore 106 | 107 | // purge older price update entries. 108 | Receive(_ => { _history = _history.Prune(DateTimeOffset.UtcNow.AddMinutes(-5)); }); 109 | 110 | Receive(t => 111 | { 112 | if (t.ActorRef.Equals(_tickerEntity)) 113 | { 114 | _log.Info("Source of truth entity terminated. Re-acquiring..."); 115 | Context.SetReceiveTimeout(TimeSpan.FromSeconds(5)); 116 | _priceActorGateway.Tell(new FetchPriceAndVolume(_tickerSymbol)); 117 | _mediator.Tell(new Unsubscribe(_priceTopic, Self)); // unsubscribe until we acquire new source of truth pricing 118 | Become(WaitingForPriceAndVolume); 119 | } 120 | }); 121 | } 122 | 123 | protected override void PreStart() 124 | { 125 | Context.SetReceiveTimeout(TimeSpan.FromSeconds(5.0)); 126 | _priceActorGateway.Tell(new FetchPriceAndVolume(_tickerSymbol)); 127 | _pruneTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromMinutes(5), 128 | TimeSpan.FromMinutes(5), Self, Prune.Instance, ActorRefs.NoSender); 129 | } 130 | 131 | protected override void PostStop() 132 | { 133 | _pruneTimer.Cancel(); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/PriceViewMaster.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Akka.Actor; 5 | using Akka.Cluster.Tools.PublishSubscribe; 6 | using Akka.Event; 7 | 8 | namespace Akka.CQRS.Pricing.Actors 9 | { 10 | /// 11 | /// Parent actor to all instances. 12 | /// 13 | public sealed class PriceViewMaster : ReceiveActor, IWithUnboundedStash 14 | { 15 | public class BeginTrackPrices 16 | { 17 | public BeginTrackPrices(IActorRef shardRegion) 18 | { 19 | ShardRegion = shardRegion; 20 | } 21 | 22 | public IActorRef ShardRegion { get; } 23 | } 24 | 25 | private readonly ILoggingAdapter _log = Context.GetLogger(); 26 | 27 | public PriceViewMaster() 28 | { 29 | WaitingForSharding(); 30 | } 31 | 32 | public IStash Stash { get; set; } 33 | 34 | private void WaitingForSharding() 35 | { 36 | Receive(b => 37 | { 38 | _log.Info("Received access to stock price mediator... Starting pricing views..."); 39 | var mediator = DistributedPubSub.Get(Context.System).Mediator; 40 | 41 | foreach (var stock in AvailableTickerSymbols.Symbols) 42 | { 43 | Context.ActorOf(Props.Create(() => new PriceVolumeViewActor(stock, b.ShardRegion, mediator)), 44 | stock); 45 | } 46 | 47 | Become(Running); 48 | Stash.UnstashAll(); 49 | }); 50 | 51 | ReceiveAny(_ => Stash.Stash()); 52 | } 53 | 54 | private void Running() 55 | { 56 | Receive(w => 57 | { 58 | var child = Context.Child(w.StockId); 59 | if (child.IsNobody()) 60 | { 61 | _log.Warning("Message received for unknown ticker symbol [{0}] - sending to dead letters.", w.StockId); 62 | } 63 | 64 | // Goes to deadletters if stock ticker symbol does not exist. 65 | child.Forward(w); 66 | }); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Actors/UnexpectedEndOfStream.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | namespace Akka.CQRS.Pricing.Actors 7 | { 8 | /// 9 | /// Send this to ourselves in the event that our Akka.Persistence.Query stream completes, which it shouldn't. 10 | /// 11 | public sealed class UnexpectedEndOfStream 12 | { 13 | public static readonly UnexpectedEndOfStream Instance = new UnexpectedEndOfStream(); 14 | private UnexpectedEndOfStream() { } 15 | } 16 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Cli/Akka.CQRS.Pricing.Cli.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | netstandard2.0 4 | Petabridge.Cmd palettes for accessing the pricing system. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Cli/PriceCmdHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using Akka.Actor; 4 | using Petabridge.Cmd.Host; 5 | using static Akka.CQRS.Pricing.Cli.PricingCmd; 6 | 7 | namespace Akka.CQRS.Pricing.Cli 8 | { 9 | /// 10 | /// The command palette handelr for . 11 | /// 12 | public sealed class PriceCommands : CommandPaletteHandler 13 | { 14 | private IActorRef _priceViewMaster; 15 | 16 | public PriceCommands(IActorRef priceViewMaster) : base(PricingCommandPalette) 17 | { 18 | _priceViewMaster = priceViewMaster; 19 | HandlerProps = Props.Create(() => new PriceCmdRouter(_priceViewMaster)); 20 | } 21 | 22 | public override Props HandlerProps { get; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Cli/PriceCmdRouter.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | using Akka.Actor; 11 | using Akka.CQRS.Pricing.Commands; 12 | using Akka.CQRS.Pricing.Views; 13 | using Petabridge.Cmd; 14 | using Petabridge.Cmd.Host; 15 | 16 | namespace Akka.CQRS.Pricing.Cli 17 | { 18 | /// 19 | /// Actor responsible for carrying out commands. 20 | /// 21 | public sealed class PriceCmdRouter : CommandHandlerActor 22 | { 23 | private IActorRef _priceViewMaster; 24 | 25 | public PriceCmdRouter(IActorRef priceViewMaster) : base(PricingCmd.PricingCommandPalette) 26 | { 27 | _priceViewMaster = priceViewMaster; 28 | 29 | Process(PricingCmd.TrackPrice.Name, (command, arguments) => 30 | { 31 | var tickerSymbol = arguments.ArgumentValues("symbol").Single(); 32 | 33 | // the tracker actor will start automatically recording price information on its own. No further action needed. 34 | var trackerActor = 35 | Context.ActorOf(Props.Create(() => new PriceTrackingActor(tickerSymbol, _priceViewMaster, Sender))); 36 | }); 37 | 38 | Process(PricingCmd.PriceHistory.Name, (command, arguments) => 39 | { 40 | var tickerSymbol = arguments.ArgumentValues("symbol").Single(); 41 | var getPriceTask = _priceViewMaster.Ask(new GetPriceHistory(tickerSymbol), TimeSpan.FromSeconds(5)); 42 | var sender = Sender; 43 | 44 | // pipe happy results back to the sender only on successful Ask 45 | getPriceTask.ContinueWith(tr => 46 | { 47 | return Enumerable.Select(tr.Result.HistoricalPrices, x => new CommandResponse(x.ToString(), false)) 48 | .Concat(new []{ CommandResponse.Empty }); 49 | }, TaskContinuationOptions.OnlyOnRanToCompletion).PipeTo(sender); 50 | 51 | // pipe unhappy results back to sender on failure 52 | getPriceTask.ContinueWith(tr => 53 | new ErroredCommandResponse($"Error while fetching price history for {tickerSymbol} - " + 54 | $"timed out after 5s"), TaskContinuationOptions.NotOnRanToCompletion) 55 | .PipeTo(sender); ; 56 | }); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Cli/PriceTrackingActor.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | using Akka.Actor; 9 | using Akka.CQRS.Pricing.Commands; 10 | using Akka.CQRS.Pricing.Events; 11 | using Akka.CQRS.Pricing.Views; 12 | using Petabridge.Cmd; 13 | 14 | namespace Akka.CQRS.Pricing.Cli 15 | { 16 | /// 17 | /// Actor responsible for populating the output for the command. 18 | /// 19 | public sealed class PriceTrackingActor : ReceiveActor, IWithUnboundedStash 20 | { 21 | private readonly string _tickerSymbol; 22 | private readonly IActorRef _priceViewActor; 23 | private readonly IActorRef _commandHandlerActor; 24 | private ICancelable _priceCheckInterval; 25 | 26 | private IPriceUpdate _currentPrice; 27 | 28 | public PriceTrackingActor(string tickerSymbol, IActorRef priceViewActor, IActorRef commandHandlerActor) 29 | { 30 | _priceViewActor = priceViewActor; 31 | _commandHandlerActor = commandHandlerActor; 32 | _tickerSymbol = tickerSymbol; 33 | 34 | WaitingForPriceHistory(); 35 | } 36 | 37 | private void WaitingForPriceHistory() 38 | { 39 | Receive(p => 40 | { 41 | if (p.HistoricalPrices.IsEmpty) 42 | { 43 | _commandHandlerActor.Tell(new CommandResponse($"No historical price data for [{_tickerSymbol}] - waiting for updates.", false)); 44 | BecomePriceUpdates(); 45 | return; 46 | } 47 | 48 | _currentPrice = p.CurrentPriceUpdate; 49 | foreach (var e in p.HistoricalPrices) 50 | { 51 | _commandHandlerActor.Tell(new CommandResponse(e.ToString(), false)); 52 | } 53 | 54 | BecomePriceUpdates(); 55 | }); 56 | 57 | Receive(t => 58 | { 59 | _commandHandlerActor.Tell(new CommandResponse($"No historical price data for [{_tickerSymbol}] - waiting for updates.", false)); 60 | BecomePriceUpdates(); 61 | }); 62 | 63 | ReceiveAny(_ => Stash.Stash()); 64 | } 65 | 66 | private void BecomePriceUpdates() 67 | { 68 | Context.SetReceiveTimeout(null); 69 | Become(PriceUpdates); 70 | Stash.UnstashAll(); 71 | } 72 | 73 | private void PriceUpdates() 74 | { 75 | Receive(p => 76 | { 77 | _currentPrice = p; 78 | _commandHandlerActor.Tell(new CommandResponse(p.ToString(), false)); 79 | }); 80 | 81 | Receive(t => 82 | { 83 | _commandHandlerActor.Tell(new CommandResponse("Price View Actor terminated.")); 84 | Context.Stop(Self); 85 | }); 86 | } 87 | 88 | protected override void PreStart() 89 | { 90 | var getlatestPrice = new GetLatestPrice(_tickerSymbol); 91 | 92 | // get the historical price 93 | _priceViewActor.Tell(new GetPriceHistory(_tickerSymbol)); 94 | _priceCheckInterval = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromSeconds(3), 95 | TimeSpan.FromSeconds(3), _priceViewActor, getlatestPrice, Self); 96 | 97 | Context.SetReceiveTimeout(TimeSpan.FromSeconds(1)); 98 | Context.Watch(_priceViewActor); 99 | } 100 | 101 | protected override void PostStop() 102 | { 103 | _priceCheckInterval.Cancel(); 104 | } 105 | 106 | public IStash Stash { get; set; } 107 | } 108 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Cli/PricingCmd.cs: -------------------------------------------------------------------------------- 1 | using Petabridge.Cmd; 2 | 3 | namespace Akka.CQRS.Pricing.Cli 4 | { 5 | /// 6 | /// Defines all of the Petabridge.Cmd commands for tracking the price. 7 | /// 8 | public static class PricingCmd 9 | { 10 | public static readonly CommandDefinition TrackPrice = new CommandDefinitionBuilder() 11 | .WithName("track").WithDescription("Track the live changes in price for a given ticker symbol continuously. Press Control + C to exit.") 12 | .WithArgument(b => b.IsMandatory(true).WithName("symbol").WithSwitch("-s").WithSwitch("-S").WithSwitch("--symbol") 13 | .WithDefaultValues(AvailableTickerSymbols.Symbols) 14 | .WithDescription("The ticker symbol for the stock.")) 15 | .Build(); 16 | 17 | public static readonly CommandDefinition PriceHistory = new CommandDefinitionBuilder() 18 | .WithName("history").WithDescription("Get the historical price history for the specified ticker symbol") 19 | .WithArgument(b => b.IsMandatory(true).WithName("symbol").WithSwitch("-s").WithSwitch("-S").WithSwitch("--symbol") 20 | .WithDefaultValues(AvailableTickerSymbols.Symbols) 21 | .WithDescription("The ticker symbol for the stock.")) 22 | .Build(); 23 | 24 | public static readonly CommandPalette PricingCommandPalette = new CommandPalette("price", new []{ TrackPrice, PriceHistory }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Service/Akka.CQRS.Pricing.Service.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | $(NetVersion) 5 | 6 | 7 | 8 | 9 | Always 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS base 2 | WORKDIR /app 3 | 4 | # should be a comma-delimited list 5 | ENV CLUSTER_SEEDS "[]" 6 | ENV CLUSTER_IP "" 7 | ENV CLUSTER_PORT "6055" 8 | ENV SQL_CONNECTION_STR "" # SQL connection string for Akka.Persistence.Sql 9 | ENV SQL_PROVIDER_NAME "" # SQL provider for Akka.Persistence.Sql 10 | 11 | COPY ./bin/Release/net7.0/publish/ /app 12 | 13 | # 9110 - Petabridge.Cmd 14 | # 6055 - Akka.Cluster 15 | EXPOSE 9110 6055 16 | 17 | # Install Petabridge.Cmd client 18 | RUN dotnet tool install --global pbm 19 | 20 | # Needed because https://stackoverflow.com/questions/51977474/install-dotnet-core-tool-dockerfile 21 | ENV PATH="${PATH}:/root/.dotnet/tools" 22 | 23 | # RUN pbm help 24 | 25 | CMD ["dotnet", "Akka.CQRS.Pricing.Service.dll"] -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Service/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Akka.Actor; 6 | using Akka.Bootstrap.Docker; 7 | using Akka.Cluster.Hosting; 8 | using Akka.Cluster.Sharding; 9 | using Akka.Cluster.Tools.PublishSubscribe; 10 | using Akka.Cluster.Tools.Singleton; 11 | using Akka.Configuration; 12 | using Akka.CQRS.Infrastructure; 13 | using Akka.CQRS.Infrastructure.Ops; 14 | using Akka.CQRS.Pricing.Actors; 15 | using Akka.CQRS.Pricing.Cli; 16 | using Akka.Hosting; 17 | using Akka.Persistence.Hosting; 18 | using Akka.Persistence.Query; 19 | using Akka.Persistence.Sql; 20 | using Akka.Persistence.Sql.Query; 21 | using Akka.Util; 22 | using Microsoft.Extensions.Hosting; 23 | using Microsoft.Extensions.Logging; 24 | using Petabridge.Cmd.Cluster; 25 | using Petabridge.Cmd.Cluster.Sharding; 26 | using Petabridge.Cmd.Host; 27 | using Petabridge.Cmd.Remote; 28 | using static Akka.CQRS.Infrastructure.SqlDbHoconHelper; 29 | 30 | namespace Akka.CQRS.Pricing.Service 31 | { 32 | public static class Program 33 | { 34 | public static async Task Main(string[] args) 35 | { 36 | var sqlConnectionString = Environment.GetEnvironmentVariable("SQL_CONNECTION_STR")?.Trim(); 37 | if (string.IsNullOrEmpty(sqlConnectionString)) 38 | { 39 | Console.WriteLine("ERROR! SQL connection string not provided. Can't start."); 40 | return -1; 41 | } 42 | Console.WriteLine($"Connecting to SQL server at {sqlConnectionString}"); 43 | 44 | var sqlProviderName = Environment.GetEnvironmentVariable("SQL_PROVIDER_NAME")?.Trim(); 45 | if (string.IsNullOrEmpty(sqlProviderName)) 46 | { 47 | Console.WriteLine("ERROR! SQL provider name not provided. Can't start."); 48 | return -1; 49 | } 50 | Console.WriteLine($"Connecting to SQL provider {sqlProviderName}"); 51 | 52 | // Need to wait for the SQL server to spin up 53 | await Task.Delay(TimeSpan.FromSeconds(15)); 54 | 55 | var config = await File.ReadAllTextAsync("app.conf"); 56 | using var host = new HostBuilder() 57 | .ConfigureServices((hostContext, services) => 58 | { 59 | 60 | services.AddAkka("AkkaPricing", options => 61 | { 62 | // Add HOCON configuration from Docker 63 | var conf = ConfigurationFactory.ParseString(config) 64 | .WithFallback(GetSqlHocon(sqlConnectionString, sqlProviderName)) 65 | .WithFallback(OpsConfig.GetOpsConfig()) 66 | .WithFallback(ClusterSharding.DefaultConfig()) 67 | .WithFallback(DistributedPubSub.DefaultConfig()) 68 | .WithFallback(SqlPersistence.DefaultConfiguration); 69 | options.AddHocon(conf.BootstrapFromDocker(), HoconAddMode.Prepend) 70 | .WithActors((system, registry) => 71 | { 72 | var priceViewMaster = system.ActorOf(Props.Create(() => new PriceViewMaster()), "prices"); 73 | registry.Register(priceViewMaster); 74 | // used to seed pricing data 75 | var readJournal = system.ReadJournalFor(SqlReadJournal.Identifier); 76 | Cluster.Cluster.Get(system).RegisterOnMemberUp(() => 77 | { 78 | var sharding = ClusterSharding.Get(system); 79 | 80 | var shardRegion = sharding.Start("priceAggregator", 81 | s => Props.Create(() => new MatchAggregator(s, readJournal)), 82 | ClusterShardingSettings.Create(system), 83 | new StockShardMsgRouter()); 84 | 85 | // used to seed pricing data 86 | var singleton = ClusterSingletonManager.Props( 87 | Props.Create(() => new PriceInitiatorActor(readJournal, shardRegion)), 88 | ClusterSingletonManagerSettings.Create( 89 | system.Settings.Config.GetConfig("akka.cluster.price-singleton"))); 90 | 91 | // start the creation of the pricing views 92 | priceViewMaster.Tell(new PriceViewMaster.BeginTrackPrices(shardRegion)); 93 | }); 94 | 95 | }) 96 | .AddPetabridgeCmd(cmd => 97 | { 98 | void RegisterPalette(CommandPaletteHandler h) 99 | { 100 | if (cmd.RegisterCommandPalette(h)) 101 | { 102 | Console.WriteLine("Petabridge.Cmd - Registered {0}", h.Palette.ModuleName); 103 | } 104 | else 105 | { 106 | Console.WriteLine("Petabridge.Cmd - DID NOT REGISTER {0}", h.Palette.ModuleName); 107 | } 108 | } 109 | 110 | var actorSystem = cmd.Sys; 111 | var actorRegistry = ActorRegistry.For(actorSystem); 112 | var priceViewMaster = actorRegistry.Get(); 113 | 114 | RegisterPalette(ClusterCommands.Instance); 115 | RegisterPalette(new RemoteCommands()); 116 | RegisterPalette(ClusterShardingCommands.Instance); 117 | RegisterPalette(new PriceCommands(priceViewMaster)); 118 | cmd.Start(); 119 | }); 120 | 121 | }); 122 | }) 123 | .ConfigureLogging((hostContext, configLogging) => 124 | { 125 | configLogging.AddConsole(); 126 | }) 127 | .UseConsoleLifetime() 128 | .Build(); 129 | await host.RunAsync(); 130 | return 0; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing.Service/app.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | actor { 3 | provider = cluster 4 | } 5 | 6 | remote { 7 | dot-netty.tcp { 8 | hostname = "127.0.0.1" 9 | port = 6055 10 | } 11 | } 12 | 13 | cluster { 14 | #will inject this node as a self-seed node at run-time 15 | seed-nodes = ["akka.tcp://AkkaPricing@127.0.0.1:6055"] 16 | roles = ["pricing-engine" , "price-events"] 17 | 18 | pub-sub { 19 | role = "price-events" 20 | } 21 | 22 | sharding { 23 | role = "pricing-engine" 24 | } 25 | 26 | price-singleton { 27 | singleton-name = "price-initiator" 28 | role = "pricing-engine" 29 | hand-over-retry-interval = 1s 30 | min-number-of-hand-over-retries = 10 31 | } 32 | } 33 | 34 | persistence { 35 | journal { 36 | plugin = "akka.persistence.journal.sql" 37 | sql { 38 | event-adapters = { 39 | stock-tagger = "Akka.CQRS.Infrastructure.StockEventTagger, Akka.CQRS.Infrastructure" 40 | } 41 | event-adapter-bindings = { 42 | "Akka.CQRS.IWithStockId, Akka.CQRS" = stock-tagger 43 | } 44 | } 45 | } 46 | 47 | snapshot-store { 48 | plugin = "akka.persistence.snapshot-store.sql" 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Akka.CQRS.Pricing.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetStandardVersion) 4 | Read-side pricing aggregates and views. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Commands/FetchPriceAndVolume.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Pricing.Commands 6 | { 7 | /// 8 | /// Fetch the N most recent price and volume updates for a specific ticker symbol. 9 | /// 10 | public sealed class FetchPriceAndVolume : IWithStockId 11 | { 12 | public FetchPriceAndVolume(string stockId) 13 | { 14 | StockId = stockId; 15 | } 16 | 17 | public string StockId { get; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Commands/GetPriceHistory.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using Akka.CQRS.Pricing.Views; 8 | 9 | namespace Akka.CQRS.Pricing.Commands 10 | { 11 | /// 12 | /// Fetch an entire for a specific stock. 13 | /// 14 | public sealed class GetPriceHistory : IWithStockId 15 | { 16 | public GetPriceHistory(string stockId) 17 | { 18 | StockId = stockId; 19 | } 20 | 21 | public string StockId { get; } 22 | } 23 | 24 | /// 25 | /// Fetch the latest price only for a specific stock. 26 | /// 27 | public sealed class GetLatestPrice : IWithStockId 28 | { 29 | public GetLatestPrice(string stockId) 30 | { 31 | StockId = stockId; 32 | } 33 | 34 | public string StockId { get; } 35 | } 36 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Commands/Ping.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Akka.CQRS.Pricing.Commands 4 | { 5 | /// 6 | /// Used to heartbeat an Akka.Cluster.Sharding entity for a specific ticker symbol. 7 | /// 8 | public sealed class Ping : IWithStockId, IEquatable 9 | { 10 | public Ping(string stockId) 11 | { 12 | StockId = stockId; 13 | } 14 | 15 | public string StockId { get; } 16 | 17 | public bool Equals(Ping other) 18 | { 19 | if (ReferenceEquals(null, other)) return false; 20 | if (ReferenceEquals(this, other)) return true; 21 | return string.Equals(StockId, other.StockId); 22 | } 23 | 24 | public override bool Equals(object obj) 25 | { 26 | if (ReferenceEquals(null, obj)) return false; 27 | if (ReferenceEquals(this, obj)) return true; 28 | return obj is Ping other && Equals(other); 29 | } 30 | 31 | public override int GetHashCode() 32 | { 33 | return StockId.GetHashCode(); 34 | } 35 | 36 | public static bool operator ==(Ping left, Ping right) 37 | { 38 | return Equals(left, right); 39 | } 40 | 41 | public static bool operator !=(Ping left, Ping right) 42 | { 43 | return !Equals(left, right); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Commands/PriceAndVolumeSnapshot.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using Akka.CQRS.Pricing.Events; 8 | 9 | namespace Akka.CQRS.Pricing.Commands 10 | { 11 | /// 12 | /// The response to a command. 13 | /// 14 | public sealed class PriceAndVolumeSnapshot : IWithStockId 15 | { 16 | public PriceAndVolumeSnapshot(string stockId, IPriceUpdate[] priceUpdates, IVolumeUpdate[] volumeUpdates) 17 | { 18 | StockId = stockId; 19 | PriceUpdates = priceUpdates; 20 | VolumeUpdates = volumeUpdates; 21 | } 22 | 23 | public string StockId { get; } 24 | 25 | public IPriceUpdate[] PriceUpdates { get; } 26 | 27 | public IVolumeUpdate[] VolumeUpdates { get; } 28 | 29 | public static readonly IPriceUpdate[] EmptyPrices = new IPriceUpdate[0]; 30 | public static readonly IVolumeUpdate[] EmptyVolumes = new IVolumeUpdate[0]; 31 | 32 | public static PriceAndVolumeSnapshot Empty(string stockId) 33 | { 34 | return new PriceAndVolumeSnapshot(stockId, EmptyPrices, EmptyVolumes); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Events/IPriceUpdate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Pricing.Events 6 | { 7 | /// 8 | /// Used to signal a change in price for a specific stock. 9 | /// 10 | public interface IPriceUpdate : IWithStockId, IComparable 11 | { 12 | /// 13 | /// The time of this price update. 14 | /// 15 | DateTimeOffset Timestamp { get; } 16 | 17 | /// 18 | /// The current volume-weighted average price. 19 | /// 20 | decimal CurrentAvgPrice { get; } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Events/IVolumeUpdate.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | 9 | namespace Akka.CQRS.Pricing.Events 10 | { 11 | /// 12 | /// Used to signal a change in volume for a specific stock. 13 | /// 14 | public interface IVolumeUpdate : IWithStockId, IComparable 15 | { 16 | /// 17 | /// The time of this price update. 18 | /// 19 | DateTimeOffset Timestamp { get; } 20 | 21 | /// 22 | /// The current trade volume. 23 | /// 24 | double CurrentVolume { get; } 25 | } 26 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Events/PriceChanged.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | using System.Collections.Generic; 9 | 10 | namespace Akka.CQRS.Pricing.Events 11 | { 12 | /// 13 | /// Concrete implementation. 14 | /// 15 | public sealed class PriceChanged : IPriceUpdate, IComparable 16 | { 17 | public PriceChanged(string stockId, decimal currentAvgPrice, DateTimeOffset timestamp) 18 | { 19 | StockId = stockId; 20 | CurrentAvgPrice = currentAvgPrice; 21 | Timestamp = timestamp; 22 | } 23 | 24 | public DateTimeOffset Timestamp { get; } 25 | 26 | public decimal CurrentAvgPrice { get; } 27 | 28 | public string StockId { get; } 29 | 30 | public int CompareTo(PriceChanged other) 31 | { 32 | if (ReferenceEquals(this, other)) return 0; 33 | if (ReferenceEquals(null, other)) return 1; 34 | return Timestamp.CompareTo(other.Timestamp); 35 | } 36 | 37 | public int CompareTo(IPriceUpdate other) 38 | { 39 | if (other is PriceChanged c) 40 | { 41 | return CompareTo(c); 42 | } 43 | throw new ArgumentException(); 44 | } 45 | 46 | public override string ToString() 47 | { 48 | return $"[{StockId}][{Timestamp}] - $[{CurrentAvgPrice}]"; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Events/VolumeChanged.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | 9 | namespace Akka.CQRS.Pricing.Events 10 | { 11 | /// 12 | /// Concrete implementation. 13 | /// 14 | public sealed class VolumeChanged : IVolumeUpdate, IComparable 15 | { 16 | public VolumeChanged(string stockId, double currentVolume, DateTimeOffset timestamp) 17 | { 18 | StockId = stockId; 19 | CurrentVolume = currentVolume; 20 | Timestamp = timestamp; 21 | } 22 | 23 | public string StockId { get; } 24 | 25 | public DateTimeOffset Timestamp { get; } 26 | public double CurrentVolume { get; } 27 | 28 | public int CompareTo(IVolumeUpdate other) 29 | { 30 | if (other is VolumeChanged c) 31 | { 32 | return CompareTo(c); 33 | } 34 | throw new ArgumentException(); 35 | } 36 | 37 | public int CompareTo(VolumeChanged other) 38 | { 39 | if (ReferenceEquals(this, other)) return 0; 40 | if (ReferenceEquals(null, other)) return 1; 41 | return Timestamp.CompareTo(other.Timestamp); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/MatchAggregatorSnapshot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Akka.CQRS.Pricing.Events; 5 | 6 | namespace Akka.CQRS.Pricing 7 | { 8 | /// 9 | /// Represents the point-in-time state of the match aggregator at any given time. 10 | /// 11 | public sealed class MatchAggregatorSnapshot 12 | { 13 | public MatchAggregatorSnapshot(long queryOffset, decimal avgPrice, double avgVolume, 14 | IReadOnlyList recentPriceUpdates, IReadOnlyList recentVolumeUpdates) 15 | { 16 | QueryOffset = queryOffset; 17 | AvgPrice = avgPrice; 18 | AvgVolume = avgVolume; 19 | RecentPriceUpdates = recentPriceUpdates; 20 | RecentVolumeUpdates = recentVolumeUpdates; 21 | } 22 | 23 | /// 24 | /// The sequence number of the Akka.Persistence.Query object to begin reading from. 25 | /// 26 | public long QueryOffset { get; } 27 | 28 | /// 29 | /// The most recently saved average price. 30 | /// 31 | public decimal AvgPrice { get; } 32 | 33 | /// 34 | /// The most recently saved average volume. 35 | /// 36 | public double AvgVolume { get; } 37 | 38 | public IReadOnlyList RecentPriceUpdates { get; } 39 | 40 | public IReadOnlyList RecentVolumeUpdates { get; } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/PriceTopicHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS.Pricing 2 | { 3 | /// 4 | /// Helper methods for working with price and volume updates. 5 | /// 6 | public static class PriceTopicHelpers 7 | { 8 | public static string PriceUpdateTopic(string tickerSymbol) 9 | { 10 | return $"{tickerSymbol}-price"; 11 | } 12 | 13 | public static string VolumeUpdateTopic(string tickerSymbol) 14 | { 15 | return $"{tickerSymbol}-update"; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Views/EMWA.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Akka.CQRS.Pricing 4 | { 5 | /// 6 | /// Simple data structure for self-contained EMWA mathematics. 7 | /// 8 | public struct EMWA 9 | { 10 | public EMWA(double alpha, double currentAvg) 11 | { 12 | Alpha = alpha; 13 | CurrentAvg = currentAvg; 14 | } 15 | 16 | public double Alpha { get; } 17 | 18 | public double CurrentAvg { get; } 19 | 20 | public EMWA Next(double nextValue) 21 | { 22 | return new EMWA(Alpha, Alpha * nextValue + (1 - Alpha) * CurrentAvg); 23 | } 24 | 25 | public static EMWA Init(int sampleSize, double firstReading) 26 | { 27 | var alpha = 2.0 / (sampleSize + 1); 28 | return new EMWA(alpha, firstReading); 29 | } 30 | 31 | public static double operator %(EMWA e1, EMWA e2) 32 | { 33 | return (e1.CurrentAvg - e2.CurrentAvg) / e1.CurrentAvg; 34 | } 35 | 36 | public static EMWA operator +(EMWA e1, double next) 37 | { 38 | return e1.Next(next); 39 | } 40 | } 41 | 42 | /// 43 | /// Simple data structure for self-contained EMWA mathematics using precision. 44 | /// 45 | public struct EMWAm 46 | { 47 | public EMWAm(decimal alpha, decimal currentAvg) 48 | { 49 | Alpha = alpha; 50 | CurrentAvg = currentAvg; 51 | } 52 | 53 | public decimal Alpha { get; } 54 | 55 | public decimal CurrentAvg { get; } 56 | 57 | public EMWAm Next(decimal nextValue) 58 | { 59 | return new EMWAm(Alpha, Alpha * nextValue + (1 - Alpha) * CurrentAvg); 60 | } 61 | 62 | public static EMWAm Init(int sampleSize, decimal firstReading) 63 | { 64 | var alpha = 2.0m / (sampleSize + 1); 65 | return new EMWAm(alpha, firstReading); 66 | } 67 | 68 | public static decimal operator %(EMWAm e1, EMWAm e2) 69 | { 70 | return (e1.CurrentAvg - e2.CurrentAvg) / e1.CurrentAvg; 71 | } 72 | 73 | public static EMWAm operator +(EMWAm e1, decimal next) 74 | { 75 | return e1.Next(next); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Views/MatchAggregate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.CQRS.Events; 3 | using Akka.CQRS.Pricing.Events; 4 | using Akka.CQRS.Util; 5 | 6 | namespace Akka.CQRS.Pricing.Views 7 | { 8 | /// 9 | /// Aggregates trade events in order to produce price and volume estimates. 10 | /// 11 | public sealed class MatchAggregate 12 | { 13 | /// 14 | /// By default, average all prices and volume over the past 30 matched trades. 15 | /// 16 | public const int DefaultSampleSize = 30; 17 | 18 | public MatchAggregate(string tickerSymbol, decimal initialPrice = 0.0m, 19 | double initialVolume = 0.0d, int sampleSize = DefaultSampleSize) 20 | { 21 | TickerSymbol = tickerSymbol; 22 | AvgPrice = EMWAm.Init(sampleSize, initialPrice); 23 | AvgVolume = EMWA.Init(sampleSize, initialVolume); 24 | } 25 | 26 | public string TickerSymbol { get; } 27 | 28 | public EMWA AvgVolume { get; private set; } 29 | 30 | public EMWAm AvgPrice { get; private set; } 31 | 32 | /// 33 | /// Fetch the current price and volume metrics. 34 | /// 35 | /// We don't do this on every single match since that could become noisy quickly. 36 | /// Instead we do it on a regular clock interval. 37 | /// 38 | /// Optional - the service used for time-stamping the price and volume updates. 39 | /// The current price and volume update events. 40 | public (IPriceUpdate lastestPrice, IVolumeUpdate latestVolume) FetchMetrics(ITimestamper timestampService = null) 41 | { 42 | var currentTime = timestampService?.Now ?? CurrentUtcTimestamper.Instance.Now; 43 | return (new PriceChanged(TickerSymbol, AvgPrice.CurrentAvg, currentTime), 44 | new VolumeChanged(TickerSymbol, AvgVolume.CurrentAvg, currentTime)); 45 | } 46 | 47 | /// 48 | /// Feed the most recent match for to update moving price averages. 49 | /// 50 | /// The most recent matched trade for this symbol. 51 | public bool WithMatch(Match latestTrade) 52 | { 53 | if (!latestTrade.StockId.Equals(TickerSymbol)) 54 | return false; // Someone fed a match for a stock other than TickerSymbol 55 | 56 | // Update EMWA quantity and volume 57 | AvgVolume += latestTrade.Quantity; 58 | AvgPrice += latestTrade.SettlementPrice; 59 | return true; 60 | } 61 | } 62 | 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Pricing/Views/PriceHistory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.Linq; 4 | using Akka.CQRS.Pricing.Events; 5 | 6 | namespace Akka.CQRS.Pricing.Views 7 | { 8 | /// 9 | /// Details the price history for a specific ticker symbol. 10 | /// 11 | /// In-memory, replicated view. 12 | /// 13 | public struct PriceHistory : IWithStockId 14 | { 15 | public PriceHistory(string stockId, ImmutableSortedSet historicalPrices) 16 | { 17 | HistoricalPrices = historicalPrices; 18 | StockId = stockId; 19 | } 20 | 21 | public string StockId { get; } 22 | 23 | public DateTimeOffset From => HistoricalPrices[0].Timestamp; 24 | 25 | public DateTimeOffset Until => CurrentPriceUpdate.Timestamp; 26 | 27 | public decimal CurrentPrice => CurrentPriceUpdate.CurrentAvgPrice; 28 | 29 | public IPriceUpdate CurrentPriceUpdate => HistoricalPrices.Last(); 30 | 31 | public TimeSpan Range => Until - From; 32 | 33 | public ImmutableSortedSet HistoricalPrices { get; } 34 | 35 | public PriceHistory WithPrice(IPriceUpdate update) 36 | { 37 | if(!update.StockId.Equals(StockId)) 38 | throw new ArgumentOutOfRangeException($"Expected ticker symbol {StockId} but found {update.StockId}", nameof(update)); 39 | 40 | return new PriceHistory(StockId, HistoricalPrices.Add(update)); 41 | } 42 | 43 | /// 44 | /// Purge older price entries - resetting the window for a new trading day. 45 | /// 46 | /// Delete any entries older than this. 47 | /// An updated . 48 | public PriceHistory Prune(DateTimeOffset earliestStart) 49 | { 50 | return new PriceHistory(StockId, HistoricalPrices.Where(x => x.Timestamp < earliestStart).ToImmutableSortedSet()); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions.Tests/Akka.CQRS.Subscriptions.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetVersion) 4 | 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions.Tests/DistributedPubSub/DistributedPubSubEnd2EndSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Akka.Actor; 6 | using Akka.Cluster; 7 | using Akka.Configuration; 8 | using Akka.CQRS.Events; 9 | using Akka.CQRS.Subscriptions.DistributedPubSub; 10 | using FluentAssertions; 11 | using Xunit; 12 | using Xunit.Abstractions; 13 | 14 | namespace Akka.CQRS.Subscriptions.Tests.DistributedPubSub 15 | { 16 | public class DistributedPubSubEnd2EndSpecs : TestKit.Xunit2.TestKit 17 | { 18 | private static readonly Config ClusterConfig = @" 19 | akka.actor.provider = cluster 20 | "; 21 | 22 | public DistributedPubSubEnd2EndSpecs(ITestOutputHelper output) 23 | : base(ClusterConfig, output: output) { } 24 | 25 | public Address SelfAddress => Cluster.Cluster.Get(Sys).SelfAddress; 26 | 27 | [Fact(DisplayName = "Should be able to subscribe and publish to trade event topics.")] 28 | public async Task ShouldSubscribeAndPublishToTradeEventTopics() 29 | { 30 | // Join the cluster 31 | Within(TimeSpan.FromSeconds(5), () => 32 | { 33 | Cluster.Cluster.Get(Sys).Join(SelfAddress); 34 | AwaitCondition( 35 | () => Cluster.Cluster.Get(Sys).State.Members.Count(x => x.Status == MemberStatus.Up) == 1); 36 | }); 37 | 38 | // Start DistributedPubSub 39 | var subManager = DistributedPubSubTradeEventSubscriptionManager.For(Sys); 40 | var published = DistributedPubSubTradeEventPublisher.For(Sys); 41 | 42 | // Subscribe to all topics 43 | var subAck = await subManager.Subscribe("MSFT", TestActor); 44 | subAck.TickerSymbol.Should().Be("MSFT"); 45 | 46 | var bid = new Bid("MSFT", "foo", 10.0m, 1.0d, DateTimeOffset.UtcNow); 47 | published.Publish("MSFT", bid); 48 | ExpectMsg(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions.Tests/DistributedPubSub/DistributedPubSubFormatterSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using FluentAssertions; 4 | using static Akka.CQRS.Subscriptions.DistributedPubSub.DistributedPubSubTopicFormatter; 5 | using Xunit; 6 | 7 | namespace Akka.CQRS.Subscriptions.Tests.DistributedPubSub 8 | { 9 | public class DistributedPubSubFormatterSpecs 10 | { 11 | public static IEnumerable GetTradeTopics() 12 | { 13 | yield return new object[] { "msft", TradeEventType.Ask, "msft-Ask" }; 14 | yield return new object[] { "msft", TradeEventType.Bid, "msft-Bid" }; 15 | yield return new object[] { "msft", TradeEventType.Match, "msft-Match" }; 16 | yield return new object[] { "msft", TradeEventType.Fill, "msft-Fill" }; 17 | } 18 | 19 | [Theory(DisplayName = "Should format name of ticker symbol + event in the format expected by DistributedPubSub")] 20 | [MemberData(nameof(GetTradeTopics))] 21 | public void ShouldFormatDistributedPubSubTopic(string ticker, TradeEventType tradeEvent, string expectedTopic) 22 | { 23 | ToTopic(ticker, tradeEvent).Should().Be(expectedTopic); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions.Tests/TradeEventExtensionsSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using Akka.CQRS.Events; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace Akka.CQRS.Subscriptions.Tests 9 | { 10 | public class TradeEventExtensionsSpecs 11 | { 12 | public static IEnumerable GetTradeEvents() 13 | { 14 | yield return new object[] {new Ask("foo", "foo", 10.0m, 1.0d, DateTimeOffset.UtcNow), TradeEventType.Ask}; 15 | yield return new object[] { new Bid("foo", "foo", 10.0m, 1.0d, DateTimeOffset.UtcNow), TradeEventType.Bid }; 16 | yield return new object[] { new Fill("foo", 1.0d, 10.0m, "bar", DateTimeOffset.UtcNow), TradeEventType.Fill }; 17 | yield return new object[] { new Match("foo", "bar", "fuber", 10.0m, 1.0d, DateTimeOffset.UtcNow), TradeEventType.Match }; 18 | 19 | } 20 | 21 | [Theory(DisplayName = "Should detect correct TradeEventType for ITradeEvent")] 22 | [MemberData(nameof(GetTradeEvents))] 23 | public void ShouldMatchEventWithTradeType(ITradeEvent tradeEvent, TradeEventType expectedType) 24 | { 25 | tradeEvent.ToTradeEventType().Should().Be(expectedType); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/Akka.CQRS.Subscriptions.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(NetStandardVersion) 5 | Pub-sub abstractions 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/DistributedPubSub/DistributedPubSubTopicFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Cluster.Tools.PublishSubscribe; 3 | 4 | namespace Akka.CQRS.Subscriptions.DistributedPubSub 5 | { 6 | /// 7 | /// Formats messages into -friendly topic names. 8 | /// 9 | public static class DistributedPubSubTopicFormatter 10 | { 11 | public static string ToTopic(string tickerSymbol, TradeEventType tradeEventType) 12 | { 13 | string ToStr(TradeEventType e) 14 | { 15 | switch (e) 16 | { 17 | case TradeEventType.Ask: 18 | return "Ask"; 19 | case TradeEventType.Bid: 20 | return "Bid"; 21 | case TradeEventType.Fill: 22 | return "Fill"; 23 | case TradeEventType.Match: 24 | return "Match"; 25 | default: 26 | throw new ArgumentOutOfRangeException(nameof(e)); 27 | } 28 | } 29 | return $"{tickerSymbol}-{ToStr(tradeEventType)}"; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/DistributedPubSub/DistributedPubSubTradeEventPublisher.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | using System.Xml; 4 | using Akka.Actor; 5 | using Akka.Cluster.Tools.PublishSubscribe; 6 | using Akka.Util; 7 | using static Akka.CQRS.Subscriptions.DistributedPubSub.DistributedPubSubTopicFormatter; 8 | 9 | namespace Akka.CQRS.Subscriptions.DistributedPubSub 10 | { 11 | /// 12 | /// used for distributing events over the . 13 | /// 14 | public sealed class DistributedPubSubTradeEventPublisher : ITradeEventPublisher 15 | { 16 | private readonly IActorRef _mediator; 17 | 18 | public DistributedPubSubTradeEventPublisher(IActorRef mediator) 19 | { 20 | _mediator = mediator; 21 | } 22 | 23 | public void Publish(string tickerSymbol, ITradeEvent @event) 24 | { 25 | var eventType = @event.ToTradeEventType(); 26 | var topic = ToTopic(tickerSymbol, eventType); 27 | _mediator.Tell(new Publish(topic, @event)); 28 | } 29 | 30 | public static DistributedPubSubTradeEventPublisher For(ActorSystem sys) 31 | { 32 | var mediator = Cluster.Tools.PublishSubscribe.DistributedPubSub.Get(sys).Mediator; 33 | return new DistributedPubSubTradeEventPublisher(mediator); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/DistributedPubSub/DistributedPubSubTradeEventSubscriptionManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Akka.Actor; 5 | using Akka.Cluster.Tools.PublishSubscribe; 6 | 7 | namespace Akka.CQRS.Subscriptions.DistributedPubSub 8 | { 9 | /// 10 | /// that uses the under the hood. 11 | /// 12 | public sealed class DistributedPubSubTradeEventSubscriptionManager : ITradeEventSubscriptionManager 13 | { 14 | private readonly IActorRef _mediator; 15 | 16 | public DistributedPubSubTradeEventSubscriptionManager(IActorRef mediator) 17 | { 18 | _mediator = mediator; 19 | } 20 | 21 | public Task Subscribe(string tickerSymbol, IActorRef subscriber) 22 | { 23 | return Subscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 24 | } 25 | 26 | public Task Subscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 27 | { 28 | return Subscribe(tickerSymbol, new[] { @event }, subscriber); 29 | } 30 | 31 | public async Task Subscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 32 | { 33 | var tasks = ToTopics(tickerSymbol, events).Select(x => 34 | _mediator.Ask(new Subscribe(x, subscriber), TimeSpan.FromSeconds(3))); 35 | 36 | await Task.WhenAll(tasks).ConfigureAwait(false); 37 | 38 | return new TradeSubscribeAck(tickerSymbol, events); 39 | } 40 | 41 | public async Task Unsubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 42 | { 43 | var tasks = ToTopics(tickerSymbol, events).Select(x => 44 | _mediator.Ask(new Unsubscribe(x, subscriber), TimeSpan.FromSeconds(3))); 45 | 46 | await Task.WhenAll(tasks).ConfigureAwait(false); 47 | 48 | return new TradeUnsubscribeAck(tickerSymbol, events); 49 | } 50 | 51 | public Task Unsubscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 52 | { 53 | return Unsubscribe(tickerSymbol, new[] { @event }, subscriber); 54 | } 55 | 56 | public Task Unsubscribe(string tickerSymbol, IActorRef subscriber) 57 | { 58 | return Unsubscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 59 | } 60 | 61 | internal static string[] ToTopics(string tickerSymbol, TradeEventType[] events) 62 | { 63 | return events.Select(x => DistributedPubSubTopicFormatter.ToTopic(tickerSymbol, x)).ToArray(); 64 | } 65 | 66 | public static DistributedPubSubTradeEventSubscriptionManager For(ActorSystem sys) 67 | { 68 | var mediator = Cluster.Tools.PublishSubscribe.DistributedPubSub.Get(sys).Mediator; 69 | return new DistributedPubSubTradeEventSubscriptionManager(mediator); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/ITradeEventPublisher.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | namespace Akka.CQRS.Subscriptions 8 | { 9 | /// 10 | /// Abstraction used for publishing data about instances. 11 | /// 12 | public interface ITradeEventPublisher 13 | { 14 | void Publish(string tickerSymbol, ITradeEvent @event); 15 | } 16 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/ITradeEventSubscriptionManager.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System.Threading.Tasks; 8 | using Akka.Actor; 9 | 10 | namespace Akka.CQRS.Subscriptions 11 | { 12 | /// 13 | /// Abstraction used to manage subscriptions for s. 14 | /// 15 | public interface ITradeEventSubscriptionManager 16 | { 17 | Task Subscribe(string tickerSymbol, IActorRef subscriber); 18 | 19 | Task Subscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber); 20 | 21 | Task Subscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber); 22 | 23 | Task Unsubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber); 24 | 25 | Task Unsubscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber); 26 | 27 | Task Unsubscribe(string tickerSymbol, IActorRef subscriber); 28 | } 29 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/InMem/InMemoryTradeEventPublisher.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Akka.Actor; 4 | 5 | namespace Akka.CQRS.Subscriptions.InMem 6 | { 7 | /// 8 | /// Used locally, in-memory by a single order book actor. Belongs to a single ticker symbol. 9 | /// 10 | public sealed class InMemoryTradeEventPublisher : ITradeEventPublisher, ITradeEventSubscriptionManager 11 | { 12 | private readonly Dictionary> _subscribers; 13 | 14 | public InMemoryTradeEventPublisher() : this(new Dictionary>()) { } 15 | 16 | public InMemoryTradeEventPublisher(Dictionary> subscribers) 17 | { 18 | _subscribers = subscribers; 19 | } 20 | 21 | public void Publish(string tickerSymbol, ITradeEvent @event) 22 | { 23 | var eventType = @event.ToTradeEventType(); 24 | EnsureSub(eventType); 25 | 26 | foreach(var sub in _subscribers[eventType]) 27 | sub.Tell(@event); 28 | } 29 | 30 | public Task Subscribe(string tickerSymbol, IActorRef subscriber) 31 | { 32 | return Subscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 33 | } 34 | 35 | public Task Subscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 36 | { 37 | return Subscribe(tickerSymbol, new[] {@event}, subscriber); 38 | } 39 | 40 | public Task Subscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 41 | { 42 | foreach (var e in events) 43 | { 44 | EnsureSub(e); 45 | _subscribers[e].Add(subscriber); 46 | } 47 | 48 | return Task.FromResult(new TradeSubscribeAck(tickerSymbol, events)); 49 | } 50 | 51 | private void EnsureSub(TradeEventType e) 52 | { 53 | if (!_subscribers.ContainsKey(e)) 54 | { 55 | _subscribers[e] = new HashSet(); 56 | } 57 | } 58 | 59 | public Task Unsubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 60 | { 61 | foreach (var e in events) 62 | { 63 | EnsureSub(e); 64 | _subscribers[e].Remove(subscriber); 65 | } 66 | 67 | return Task.FromResult(new TradeUnsubscribeAck(tickerSymbol, events)); 68 | } 69 | 70 | public Task Unsubscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 71 | { 72 | return Unsubscribe(tickerSymbol, new []{ @event }, subscriber); 73 | } 74 | 75 | public Task Unsubscribe(string tickerSymbol, IActorRef subscriber) 76 | { 77 | return Unsubscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/NoOp/NoOpTradeEventSubscriptionManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Akka.Actor; 6 | 7 | namespace Akka.CQRS.Subscriptions.NoOp 8 | { 9 | /// 10 | /// Used to ignore subscription management events. 11 | /// 12 | public sealed class NoOpTradeEventSubscriptionManager : ITradeEventSubscriptionManager 13 | { 14 | public static readonly NoOpTradeEventSubscriptionManager Instance = new NoOpTradeEventSubscriptionManager(); 15 | private NoOpTradeEventSubscriptionManager() { } 16 | 17 | public Task Subscribe(string tickerSymbol, IActorRef subscriber) 18 | { 19 | return Subscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 20 | } 21 | 22 | public Task Subscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 23 | { 24 | return Subscribe(tickerSymbol, new []{ @event }, subscriber); 25 | } 26 | 27 | public Task Subscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 28 | { 29 | return Task.FromResult(new TradeSubscribeAck(tickerSymbol, TradeEventHelpers.AllTradeEventTypes)); 30 | } 31 | 32 | public Task Unsubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 33 | { 34 | return Task.FromResult(new TradeUnsubscribeAck(tickerSymbol, events)); 35 | } 36 | 37 | public Task Unsubscribe(string tickerSymbol, TradeEventType @event, IActorRef subscriber) 38 | { 39 | return Unsubscribe(tickerSymbol, new[] {@event}, subscriber); 40 | } 41 | 42 | public Task Unsubscribe(string tickerSymbol, IActorRef subscriber) 43 | { 44 | return Unsubscribe(tickerSymbol, TradeEventHelpers.AllTradeEventTypes, subscriber); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeEventHelpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Akka.CQRS.Events; 4 | 5 | namespace Akka.CQRS.Subscriptions 6 | { 7 | /// 8 | /// Extension methods for working with 9 | /// 10 | public static class TradeEventHelpers 11 | { 12 | public static readonly TradeEventType[] AllTradeEventTypes = 13 | Enum.GetValues(typeof(TradeEventType)).Cast().ToArray(); 14 | 15 | public static TradeEventType ToTradeEventType(this ITradeEvent @event) 16 | { 17 | switch (@event) 18 | { 19 | case Bid b: 20 | return TradeEventType.Bid; 21 | case Ask a: 22 | return TradeEventType.Ask; 23 | case Fill f: 24 | return TradeEventType.Fill; 25 | case Match m: 26 | return TradeEventType.Match; 27 | default: 28 | throw new ArgumentOutOfRangeException($"[{@event}] is not a supported trade event type.", nameof(@event)); 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeEventType.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Akka.CQRS.Subscriptions 4 | { 5 | /// 6 | /// The type of trade event we're interested in. 7 | /// 8 | public enum TradeEventType 9 | { 10 | Bid, 11 | Ask, 12 | Fill, 13 | Match 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeSubscribe.cs: -------------------------------------------------------------------------------- 1 | using Akka.Actor; 2 | 3 | namespace Akka.CQRS.Subscriptions 4 | { 5 | /// 6 | /// Subscribe to trade events for the specified ticker symbol. 7 | /// 8 | public sealed class TradeSubscribe 9 | { 10 | public TradeSubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 11 | { 12 | TickerSymbol = tickerSymbol; 13 | Events = events; 14 | Subscriber = subscriber; 15 | } 16 | 17 | public string TickerSymbol { get; } 18 | 19 | public TradeEventType[] Events { get; } 20 | 21 | public IActorRef Subscriber { get; } 22 | } 23 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeSubscribeAck.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS.Subscriptions 2 | { 3 | /// 4 | /// Subscription to a specific ticker has been successful. 5 | /// 6 | public sealed class TradeSubscribeAck 7 | { 8 | public TradeSubscribeAck(string tickerSymbol, TradeEventType[] events) 9 | { 10 | TickerSymbol = tickerSymbol; 11 | Events = events; 12 | } 13 | 14 | public string TickerSymbol { get; } 15 | 16 | public TradeEventType[] Events { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeSubscribeNack.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS.Subscriptions 2 | { 3 | /// 4 | /// Subscription to a specific ticker was not successful. 5 | /// 6 | public sealed class TradeSubscribeNack 7 | { 8 | public TradeSubscribeNack(string tickerSymbol, TradeEventType[] events, string reason) 9 | { 10 | TickerSymbol = tickerSymbol; 11 | Events = events; 12 | Reason = reason; 13 | } 14 | 15 | public string TickerSymbol { get; } 16 | 17 | public TradeEventType[] Events { get; } 18 | 19 | public string Reason { get; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeUnsubscribe.cs: -------------------------------------------------------------------------------- 1 | using Akka.Actor; 2 | 3 | namespace Akka.CQRS.Subscriptions 4 | { 5 | /// 6 | /// Unsubscribe to trade events for the specified ticker symbol. 7 | /// 8 | public sealed class TradeUnsubscribe 9 | { 10 | public TradeUnsubscribe(string tickerSymbol, TradeEventType[] events, IActorRef subscriber) 11 | { 12 | TickerSymbol = tickerSymbol; 13 | Events = events; 14 | Subscriber = subscriber; 15 | } 16 | 17 | public string TickerSymbol { get; } 18 | 19 | public TradeEventType[] Events { get; } 20 | 21 | public IActorRef Subscriber { get; } 22 | } 23 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeUnsubscribeAck.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS.Subscriptions 2 | { 3 | /// 4 | /// Unsubscription to a specific ticker has been successful. 5 | /// 6 | public sealed class TradeUnsubscribeAck 7 | { 8 | public TradeUnsubscribeAck(string tickerSymbol, TradeEventType[] events) 9 | { 10 | TickerSymbol = tickerSymbol; 11 | Events = events; 12 | } 13 | 14 | public string TickerSymbol { get; } 15 | 16 | public TradeEventType[] Events { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Subscriptions/TradeUnsubscribeNack.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS.Subscriptions 2 | { 3 | /// 4 | /// Unsubscribe from a specific ticker was not successful. 5 | /// 6 | public sealed class TradeUnsubscribeNack 7 | { 8 | public TradeUnsubscribeNack(string tickerSymbol, TradeEventType[] events, string reason) 9 | { 10 | TickerSymbol = tickerSymbol; 11 | Events = events; 12 | Reason = reason; 13 | } 14 | 15 | public string TickerSymbol { get; } 16 | 17 | public TradeEventType[] Events { get; } 18 | 19 | public string Reason { get; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.Tests/Akka.CQRS.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetVersion) 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Akka.CQRS.Tests/OrderSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.CQRS.Events; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace Akka.CQRS.Tests 7 | { 8 | public class OrderSpecs 9 | { 10 | [Fact(DisplayName = "Sell-side orders should be able to be completely filled")] 11 | public void SellTradesShouldCompletelyFill() 12 | { 13 | var ask = new Ask("MSFT", "foo", 10.0m, 2.0d, DateTimeOffset.UtcNow); 14 | var trade = ask.ToOrder(); 15 | 16 | var fill = new Fill(ask.OrderId, ask.StockId, ask.AskQuantity, ask.AskPrice, "bar", DateTimeOffset.UtcNow); 17 | 18 | var filledTrade = trade.WithFill(fill); 19 | filledTrade.Completed.Should().BeTrue(); 20 | filledTrade.RemainingQuantity.Should().Be(0.0D); 21 | } 22 | 23 | [Fact(DisplayName = "Sell-side orders should be able to be partially filled")] 24 | public void SellTradesShouldPartiallyFill() 25 | { 26 | var ask = new Ask("MSFT", "foo", 10.0m, 2.0d, DateTimeOffset.UtcNow); 27 | var trade = ask.ToOrder(); 28 | 29 | // partial fill 30 | var fill = new Fill(ask.OrderId, ask.StockId, ask.AskQuantity - 1.0d, ask.AskPrice, "bar", DateTimeOffset.UtcNow); 31 | 32 | var filledTrade = trade.WithFill(fill); 33 | filledTrade.Completed.Should().BeFalse(); 34 | filledTrade.RemainingQuantity.Should().Be(1.0D); 35 | } 36 | 37 | [Fact(DisplayName = "Buy-side orders should be able to be completely filled")] 38 | public void BuyTradesShouldCompletelyFill() 39 | { 40 | var bid = new Bid("MSFT", "foo", 10.0m, 2.0d, DateTimeOffset.UtcNow); 41 | var trade = bid.ToOrder(); 42 | 43 | var fill = new Fill(bid.OrderId, bid.StockId, bid.BidQuantity, bid.BidPrice, "bar", DateTimeOffset.UtcNow); 44 | 45 | var filledTrade = trade.WithFill(fill); 46 | filledTrade.Completed.Should().BeTrue(); 47 | filledTrade.RemainingQuantity.Should().Be(0.0D); 48 | } 49 | 50 | [Fact(DisplayName = "Buy-side orders should be able to be partially filled")] 51 | public void BuyTradesShouldPartiallyFill() 52 | { 53 | var bid = new Bid("MSFT", "foo", 10.0m, 2.0d, DateTimeOffset.UtcNow); 54 | var trade = bid.ToOrder(); 55 | 56 | var fill = new Fill(bid.OrderId, bid.StockId, bid.BidQuantity - 1.0d, bid.BidPrice, "bar", DateTimeOffset.UtcNow); 57 | 58 | var filledTrade = trade.WithFill(fill); 59 | filledTrade.Completed.Should().BeFalse(); 60 | filledTrade.RemainingQuantity.Should().Be(1.0D); 61 | } 62 | 63 | [Theory(DisplayName = "Complementary orders should match correctly")] 64 | [InlineData(1.0, 1.0, true)] 65 | [InlineData(2.0, 1.0, true)] 66 | [InlineData(1.0, 2.0, false)] 67 | public void OrdersShouldMatch(decimal bidPrice, decimal askPrice, bool match) 68 | { 69 | var ask = new Ask("PTB", "foo", askPrice, 1.0d, DateTimeOffset.UtcNow); 70 | var bid = new Bid("PTB", "bar", bidPrice, 1.0d, DateTimeOffset.UtcNow); 71 | 72 | var aOrder = ask.ToOrder(); 73 | var bOrder = bid.ToOrder(); 74 | 75 | // matching rules must be consistent in both directions 76 | aOrder.Match(bOrder).Should().Be(match); 77 | bOrder.Match(aOrder).Should().Be(match); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradePlacers.Service/Akka.CQRS.TradePlacers.Service.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | $(NetVersion) 6 | 7 | 8 | 9 | 10 | Always 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradePlacers.Service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS base 2 | WORKDIR /app 3 | 4 | # should be a comma-delimited list 5 | ENV CLUSTER_SEEDS "[]" 6 | ENV CLUSTER_IP "" 7 | ENV CLUSTER_PORT "5054" 8 | 9 | COPY ./bin/Release/net7.0/publish/ /app 10 | 11 | # 9110 - Petabridge.Cmd 12 | # 5055 - Akka.Cluster 13 | EXPOSE 9110 5054 14 | 15 | # Install Petabridge.Cmd client 16 | RUN dotnet tool install --global pbm 17 | 18 | # Needed because https://stackoverflow.com/questions/51977474/install-dotnet-core-tool-dockerfile 19 | ENV PATH="${PATH}:/root/.dotnet/tools" 20 | 21 | # RUN pbm help 22 | 23 | CMD ["dotnet", "Akka.CQRS.TradePlacers.Service.dll"] -------------------------------------------------------------------------------- /src/Akka.CQRS.TradePlacers.Service/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using Akka.Actor; 5 | using Akka.Bootstrap.Docker; 6 | using Akka.Cluster.Hosting; 7 | using Akka.Cluster.Sharding; 8 | using Akka.Cluster.Tools.PublishSubscribe; 9 | using Akka.Configuration; 10 | using Akka.CQRS.Infrastructure; 11 | using Akka.CQRS.Infrastructure.Ops; 12 | using Akka.CQRS.TradeProcessor.Actors; 13 | using Akka.Hosting; 14 | using Akka.Util; 15 | using Microsoft.Extensions.DependencyInjection; 16 | using Microsoft.Extensions.Hosting; 17 | using Petabridge.Cmd.Cluster; 18 | using Petabridge.Cmd.Cluster.Sharding; 19 | using Petabridge.Cmd.Host; 20 | using Petabridge.Cmd.Remote; 21 | using Microsoft.Extensions.Logging; 22 | 23 | namespace Akka.CQRS.TradePlacers.Service 24 | { 25 | class Program 26 | { 27 | static int Main(string[] args) 28 | { 29 | var config = File.ReadAllText("app.conf"); 30 | using var host = new HostBuilder() 31 | .ConfigureServices((hostContext, services) => 32 | { 33 | 34 | services.AddAkka("AkkaTrader", options => 35 | { 36 | // Add HOCON configuration from Docker 37 | var conf = ConfigurationFactory.ParseString(config) 38 | .WithFallback(OpsConfig.GetOpsConfig()) 39 | .WithFallback(ClusterSharding.DefaultConfig()) 40 | .WithFallback(DistributedPubSub.DefaultConfig()); 41 | options.AddHocon(conf.BootstrapFromDocker(), HoconAddMode.Prepend) 42 | .WithActors((system, registry) => 43 | { 44 | Cluster.Cluster.Get(system).RegisterOnMemberUp(() => 45 | { 46 | var sharding = ClusterSharding.Get(system); 47 | 48 | var shardRegionProxy = sharding.StartProxy("orderBook", "trade-processor", new StockShardMsgRouter()); 49 | foreach (var stock in AvailableTickerSymbols.Symbols) 50 | { 51 | var max = (decimal)ThreadLocalRandom.Current.Next(20, 45); 52 | var min = (decimal)ThreadLocalRandom.Current.Next(10, 15); 53 | var range = new PriceRange(min, 0.0m, max); 54 | 55 | // start bidders 56 | foreach (var i in Enumerable.Repeat(1, ThreadLocalRandom.Current.Next(1, 2))) 57 | { 58 | system.ActorOf(Props.Create(() => new BidderActor(stock, range, shardRegionProxy))); 59 | } 60 | 61 | // start askers 62 | foreach (var i in Enumerable.Repeat(1, ThreadLocalRandom.Current.Next(1, 2))) 63 | { 64 | system.ActorOf(Props.Create(() => new AskerActor(stock, range, shardRegionProxy))); 65 | } 66 | } 67 | }); 68 | }) 69 | .AddPetabridgeCmd(cmd => 70 | { 71 | cmd.RegisterCommandPalette(ClusterCommands.Instance); 72 | cmd.RegisterCommandPalette(ClusterShardingCommands.Instance); 73 | cmd.RegisterCommandPalette(new RemoteCommands()); 74 | cmd.Start(); 75 | }); 76 | 77 | }); 78 | }) 79 | .ConfigureLogging((hostContext, configLogging) => 80 | { 81 | configLogging.AddConsole(); 82 | }) 83 | .UseConsoleLifetime() 84 | .Build(); 85 | host.Run(); 86 | Console.ReadLine(); 87 | return 0; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradePlacers.Service/app.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | actor { 3 | provider = cluster 4 | } 5 | 6 | remote { 7 | dot-netty.tcp { 8 | hostname = "127.0.0.1" 9 | port = 5054 10 | } 11 | } 12 | 13 | cluster { 14 | #will inject this node as a self-seed node at run-time 15 | seed-nodes = ["akka.tcp://AkkaTrader@127.0.0.1:5055"] 16 | roles = ["trader", "trade-events"] 17 | 18 | pub-sub{ 19 | role = "trade-events" 20 | } 21 | 22 | sharding{ 23 | role = "trade-processor" 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Actors/Akka.CQRS.TradeProcessor.Actors.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetStandardVersion) 4 | Matching engine actors. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Actors/AskerActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Akka.Actor; 6 | using Akka.CQRS.Events; 7 | using Akka.CQRS.Subscriptions; 8 | using Akka.CQRS.Subscriptions.DistributedPubSub; 9 | using Akka.CQRS.Util; 10 | using Akka.Event; 11 | using Akka.Persistence.Extras; 12 | using Akka.Util; 13 | 14 | namespace Akka.CQRS.TradeProcessor.Actors 15 | { 16 | /// 17 | /// Actor that randomly places Asks for a ticker symbol. 18 | /// 19 | public sealed class AskerActor : ReceiveActor 20 | { 21 | private readonly string _tickerSymbol; 22 | private readonly string _traderId; 23 | private readonly ILoggingAdapter _log = Context.GetLogger(); 24 | private readonly ITradeEventSubscriptionManager _subscriptionManager; 25 | private readonly ITradeOrderIdGenerator _tradeOrderIdGenerator; 26 | private readonly ITimestamper _timestampGenerator; 27 | 28 | // tradeGateway is usefully going to be a Cluster.Sharding.RegionProxy 29 | private readonly IActorRef _tradeGateway; 30 | private readonly PriceRange _targetRange; 31 | private readonly Dictionary _asks = new Dictionary(); 32 | private readonly List _fills = new List(); 33 | private long _confirmationId = 0; 34 | private ICancelable _askInterval; 35 | 36 | private class DoSubscribe 37 | { 38 | public static readonly DoSubscribe Instance = new DoSubscribe(); 39 | private DoSubscribe() { } 40 | } 41 | 42 | private class DoAsk 43 | { 44 | public static readonly DoAsk Instance = new DoAsk(); 45 | private DoAsk() { } 46 | } 47 | 48 | public AskerActor(string tickerSymbol, PriceRange targetRange, IActorRef tradeGateway) 49 | : this(tickerSymbol, DistributedPubSubTradeEventSubscriptionManager.For(Context.System), tradeGateway, targetRange, 50 | GuidTradeOrderIdGenerator.Instance, CurrentUtcTimestamper.Instance) 51 | { } 52 | 53 | public AskerActor(string tickerSymbol, ITradeEventSubscriptionManager subscriptionManager, 54 | IActorRef tradeGateway, PriceRange targetRange, ITradeOrderIdGenerator tradeOrderIdGenerator, 55 | ITimestamper timestampGenerator) 56 | { 57 | _tickerSymbol = tickerSymbol; 58 | _subscriptionManager = subscriptionManager; 59 | _tradeGateway = tradeGateway; 60 | _targetRange = targetRange; 61 | _tradeOrderIdGenerator = tradeOrderIdGenerator; 62 | _timestampGenerator = timestampGenerator; 63 | _traderId = $"{_tickerSymbol}-{_tradeOrderIdGenerator.NextId()}"; 64 | Self.Tell(DoSubscribe.Instance); 65 | Subscribing(); 66 | } 67 | 68 | private void Subscribing() 69 | { 70 | ReceiveAsync(async _ => 71 | { 72 | try 73 | { 74 | var ack = await _subscriptionManager.Subscribe(_tickerSymbol, TradeEventType.Fill, Self); 75 | Become(Asking); 76 | _askInterval = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromSeconds(1), 77 | TimeSpan.FromSeconds(10), Self, DoAsk.Instance, ActorRefs.NoSender); 78 | } 79 | catch (Exception ex) 80 | { 81 | _log.Error(ex, "Error while waiting for SubscribeAck for [{0}-{1}] - retrying in 5s.", _tickerSymbol, TradeEventType.Fill); 82 | Context.System.Scheduler.ScheduleTellOnce(TimeSpan.FromSeconds(5), Self, DoSubscribe.Instance, ActorRefs.NoSender); 83 | } 84 | }); 85 | } 86 | 87 | private void Asking() 88 | { 89 | // Time to place a new ask 90 | Receive(_ => 91 | { 92 | var ask = CreateAsk(); 93 | _asks[ask.OrderId] = ask; 94 | _tradeGateway.Tell(new ConfirmableMessage(ask, _confirmationId++, _traderId)); 95 | _log.Info("ASK ${0} for {1} units of {2}", ask.AskPrice, ask.AskQuantity, _tickerSymbol); 96 | }); 97 | 98 | Receive(f => _asks.ContainsKey(f.OrderId), f => 99 | { 100 | _fills.Add(f); 101 | _log.Info("Received FILL for ASK order {0} of {1} stock @ ${2} per unit for {3} units", f.OrderId, f.StockId, f.Price, f.Quantity); 102 | _log.Info("We have sold {0} units of {1} at AVG price of {2}", _fills.Sum(x => x.Quantity), _tickerSymbol, _fills.Average(x => (decimal)x.Quantity * x.Price)); 103 | }); 104 | } 105 | 106 | private Ask CreateAsk() 107 | { 108 | var price = ThreadLocalRandom.Current.WithinRange(_targetRange); 109 | var quantity = ThreadLocalRandom.Current.Next(1, 20); 110 | var orderId = _tradeOrderIdGenerator.NextId(); 111 | var ask = new Ask(_tickerSymbol, orderId, price, quantity, _timestampGenerator.Now); 112 | return ask; 113 | } 114 | 115 | protected override void PostStop() 116 | { 117 | _askInterval?.Cancel(); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Actors/BidderActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Akka.Actor; 6 | using Akka.CQRS.Events; 7 | using Akka.CQRS.Subscriptions; 8 | using Akka.CQRS.Subscriptions.DistributedPubSub; 9 | using Akka.CQRS.Util; 10 | using Akka.Event; 11 | using Akka.Persistence.Extras; 12 | using Akka.Util; 13 | 14 | namespace Akka.CQRS.TradeProcessor.Actors 15 | { 16 | /// 17 | /// Actor that randomly places bids for a specific ticker symbol. 18 | /// 19 | public sealed class BidderActor : ReceiveActor 20 | { 21 | private readonly string _tickerSymbol; 22 | private readonly string _traderId; 23 | private readonly ILoggingAdapter _log = Context.GetLogger(); 24 | private readonly ITradeEventSubscriptionManager _subscriptionManager; 25 | private readonly ITradeOrderIdGenerator _tradeOrderIdGenerator; 26 | private readonly ITimestamper _timestampGenerator; 27 | 28 | // tradeGateway is usefully going to be a Cluster.Sharding.RegionProxy 29 | private readonly IActorRef _tradeGateway; 30 | private readonly PriceRange _targetRange; 31 | private readonly Dictionary _bids = new Dictionary(); 32 | private readonly List _fills = new List(); 33 | private long _confirmationId = 0; 34 | private ICancelable _bidInterval; 35 | 36 | private class DoSubscribe 37 | { 38 | public static readonly DoSubscribe Instance = new DoSubscribe(); 39 | private DoSubscribe() { } 40 | } 41 | 42 | private class DoBid 43 | { 44 | public static readonly DoBid Instance = new DoBid(); 45 | private DoBid() { } 46 | } 47 | 48 | public BidderActor(string tickerSymbol, PriceRange targetRange, IActorRef tradeGateway) 49 | : this(tickerSymbol, DistributedPubSubTradeEventSubscriptionManager.For(Context.System), tradeGateway, targetRange, 50 | GuidTradeOrderIdGenerator.Instance, CurrentUtcTimestamper.Instance) 51 | { } 52 | 53 | public BidderActor(string tickerSymbol, ITradeEventSubscriptionManager subscriptionManager, 54 | IActorRef tradeGateway, PriceRange targetRange, ITradeOrderIdGenerator tradeOrderIdGenerator, 55 | ITimestamper timestampGenerator) 56 | { 57 | _tickerSymbol = tickerSymbol; 58 | _subscriptionManager = subscriptionManager; 59 | _tradeGateway = tradeGateway; 60 | _targetRange = targetRange; 61 | _tradeOrderIdGenerator = tradeOrderIdGenerator; 62 | _timestampGenerator = timestampGenerator; 63 | _traderId = $"{_tickerSymbol}-{_tradeOrderIdGenerator.NextId()}"; 64 | Self.Tell(DoSubscribe.Instance); 65 | Subscribing(); 66 | } 67 | 68 | private void Subscribing() 69 | { 70 | ReceiveAsync(async _ => 71 | { 72 | try 73 | { 74 | var ack = await _subscriptionManager.Subscribe(_tickerSymbol, TradeEventType.Fill, Self); 75 | Become(Bidding); 76 | _bidInterval = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.FromSeconds(1), 77 | TimeSpan.FromSeconds(10), Self, DoBid.Instance, ActorRefs.NoSender); 78 | } 79 | catch (Exception ex) 80 | { 81 | _log.Error(ex, "Error while waiting for SubscribeAck for [{0}-{1}] - retrying in 5s.", _tickerSymbol, TradeEventType.Fill); 82 | Context.System.Scheduler.ScheduleTellOnce(TimeSpan.FromSeconds(5), Self, DoSubscribe.Instance, ActorRefs.NoSender); 83 | } 84 | }); 85 | } 86 | 87 | private void Bidding() 88 | { 89 | // Time to place a new bid 90 | Receive(_ => 91 | { 92 | var bid = CreateBid(); 93 | _bids[bid.OrderId] = bid; 94 | _tradeGateway.Tell(new ConfirmableMessage(bid, _confirmationId++, _traderId)); 95 | _log.Info("BID ${0} for {1} units of {2}", bid.BidPrice, bid.BidQuantity, _tickerSymbol); 96 | }); 97 | 98 | Receive(f => _bids.ContainsKey(f.OrderId), f => 99 | { 100 | _fills.Add(f); 101 | _log.Info("Received FILL for BID order {0} of {1} stock @ ${2} per unit for {3} units", f.OrderId, f.StockId, f.Price, f.Quantity); 102 | _log.Info("We now own {0} units of {1} at AVG price of {2}", _fills.Sum(x => x.Quantity), _tickerSymbol, _fills.Average(x => (decimal)x.Quantity * x.Price)); 103 | }); 104 | } 105 | 106 | private Bid CreateBid() 107 | { 108 | var price = ThreadLocalRandom.Current.WithinRange(_targetRange); 109 | var quantity = ThreadLocalRandom.Current.Next(1, 20); 110 | var orderId = _tradeOrderIdGenerator.NextId(); 111 | var bid = new Bid(_tickerSymbol, orderId, price, quantity, _timestampGenerator.Now); 112 | return bid; 113 | } 114 | 115 | protected override void PostStop() 116 | { 117 | _bidInterval?.Cancel(); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Actors/OrderBookActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Akka.Actor; 5 | using Akka.CQRS.Commands; 6 | using Akka.CQRS.Events; 7 | using Akka.CQRS.Matching; 8 | using Akka.CQRS.Subscriptions; 9 | using Akka.CQRS.Subscriptions.DistributedPubSub; 10 | using Akka.CQRS.Subscriptions.NoOp; 11 | using Akka.Event; 12 | using Akka.Persistence; 13 | using Akka.Persistence.Extras; 14 | using Akka.Util.Internal; 15 | 16 | namespace Akka.CQRS.TradeProcessor.Actors 17 | { 18 | /// 19 | /// Actor responsible for processing orders for a specific ticker symbol. 20 | /// 21 | public class OrderBookActor : ReceivePersistentActor 22 | { 23 | public static Props PropsFor(string tickerSymbol) 24 | { 25 | return Props.Create(() => new OrderBookActor(tickerSymbol)); 26 | } 27 | 28 | /// 29 | /// Take a snapshot every N messages persisted. 30 | /// 31 | public const int SnapshotInterval = 100; 32 | private MatchingEngine _matchingEngine; 33 | private readonly ITradeEventPublisher _publisher; 34 | private readonly ITradeEventSubscriptionManager _subscriptionManager; 35 | private readonly IActorRef _confirmationActor; 36 | 37 | private readonly ILoggingAdapter _log = Context.GetLogger(); 38 | 39 | public OrderBookActor(string tickerSymbol) : this(tickerSymbol, null, DistributedPubSubTradeEventPublisher.For(Context.System), NoOpTradeEventSubscriptionManager.Instance, Context.Parent) { } 40 | public OrderBookActor(string tickerSymbol, IActorRef confirmationActor) : this(tickerSymbol, null, DistributedPubSubTradeEventPublisher.For(Context.System), NoOpTradeEventSubscriptionManager.Instance, confirmationActor) { } 41 | public OrderBookActor(string tickerSymbol, MatchingEngine matchingEngine, ITradeEventPublisher publisher, ITradeEventSubscriptionManager subscriptionManager, IActorRef confirmationActor) 42 | { 43 | TickerSymbol = tickerSymbol; 44 | PersistenceId = $"{TickerSymbol}-orderBook"; 45 | _matchingEngine = matchingEngine ?? CreateDefaultMatchingEngine(tickerSymbol, _log); 46 | _publisher = publisher; 47 | _confirmationActor = confirmationActor; 48 | _subscriptionManager = subscriptionManager; 49 | 50 | Recovers(); 51 | Commands(); 52 | } 53 | 54 | private static MatchingEngine CreateDefaultMatchingEngine(string tickerSymbol, ILoggingAdapter logger) 55 | { 56 | return new MatchingEngine(tickerSymbol, logger); 57 | } 58 | 59 | public string TickerSymbol { get; } 60 | public override string PersistenceId { get; } 61 | 62 | private void Recovers() 63 | { 64 | Recover(offer => 65 | { 66 | if (offer.Snapshot is OrderbookSnapshot orderBook) 67 | { 68 | _matchingEngine = MatchingEngine.FromSnapshot(orderBook, _log); 69 | } 70 | }); 71 | 72 | Recover(b => { _matchingEngine.WithBid(b); }); 73 | Recover(a => { _matchingEngine.WithAsk(a); }); 74 | 75 | // Fill and Match can't modify the state of the MatchingEngine. 76 | Recover(m => { }); 77 | Recover(f => { }); 78 | } 79 | 80 | private void Commands() 81 | { 82 | Command>(a => 83 | { 84 | 85 | // For the sake of efficiency - update orderbook and then persist all events 86 | var events = _matchingEngine.WithAsk(a.Message); 87 | var persistableEvents = new ITradeEvent[] { a.Message }.Concat(events); // ask needs to go before Fill / Match 88 | 89 | PersistAll(persistableEvents, @event => 90 | { 91 | _log.Info("[{0}][{1}] - {2} units @ {3} per unit", TickerSymbol, @event.ToTradeEventType(), a.Message.AskQuantity, a.Message.AskPrice); 92 | if (@event is Ask) 93 | { 94 | // need to use the ID of the original sender to satisfy the PersistenceSupervisor 95 | //_confirmationActor.Tell(new Confirmation(a.ConfirmationId, a.SenderId)); 96 | } 97 | _publisher.Publish(TickerSymbol, @event); 98 | 99 | // Take a snapshot every N messages to optimize recovery time 100 | if (LastSequenceNr % SnapshotInterval == 0) 101 | { 102 | SaveSnapshot(_matchingEngine.GetSnapshot()); 103 | } 104 | }); 105 | }); 106 | 107 | Command>(b => 108 | { 109 | // For the sake of efficiency -update orderbook and then persist all events 110 | var events = _matchingEngine.WithBid(b.Message); 111 | var persistableEvents = new ITradeEvent[] { b.Message }.Concat(events); // bid needs to go before Fill / Match 112 | 113 | PersistAll(persistableEvents, @event => 114 | { 115 | _log.Info("[{0}][{1}] - {2} units @ {3} per unit", TickerSymbol, @event.ToTradeEventType(), b.Message.BidQuantity, b.Message.BidPrice); 116 | if (@event is Bid) 117 | { 118 | //_confirmationActor.Tell(new Confirmation(b.ConfirmationId, PersistenceId)); 119 | } 120 | _publisher.Publish(TickerSymbol, @event); 121 | 122 | // Take a snapshot every N messages to optimize recovery time 123 | if (LastSequenceNr % SnapshotInterval == 0) 124 | { 125 | SaveSnapshot(_matchingEngine.GetSnapshot()); 126 | } 127 | }); 128 | }); 129 | 130 | Command(success => 131 | { 132 | //DeleteMessages(success.Metadata.SequenceNr); 133 | DeleteSnapshots(new SnapshotSelectionCriteria(success.Metadata.SequenceNr)); 134 | }); 135 | 136 | /* 137 | * Handle subscriptions directly in case we're using in-memory, local pub-sub. 138 | */ 139 | CommandAsync(async sub => 140 | { 141 | try 142 | { 143 | var ack = await _subscriptionManager.Subscribe(sub.TickerSymbol, sub.Events, sub.Subscriber); 144 | Context.Watch(sub.Subscriber); 145 | sub.Subscriber.Tell(ack); 146 | } 147 | catch (Exception ex) 148 | { 149 | _log.Error(ex, "Error while processing subscription {0}", sub); 150 | sub.Subscriber.Tell(new TradeSubscribeNack(sub.TickerSymbol, sub.Events, ex.Message)); 151 | } 152 | }); 153 | 154 | CommandAsync(async unsub => 155 | { 156 | try 157 | { 158 | var ack = await _subscriptionManager.Unsubscribe(unsub.TickerSymbol, unsub.Events, unsub.Subscriber); 159 | // leave DeathWatch intact, in case actor is still subscribed to additional topics 160 | unsub.Subscriber.Tell(ack); 161 | } 162 | catch (Exception ex) 163 | { 164 | _log.Error(ex, "Error while processing unsubscribe {0}", unsub); 165 | unsub.Subscriber.Tell(new TradeUnsubscribeNack(unsub.TickerSymbol, unsub.Events, ex.Message)); 166 | } 167 | }); 168 | 169 | CommandAsync(async t => 170 | { 171 | try 172 | { 173 | var ack = await _subscriptionManager.Unsubscribe(TickerSymbol, t.ActorRef); 174 | } 175 | catch (Exception ex) 176 | { 177 | _log.Error(ex, "Error while processing unsubscribe for terminated subscriber {0} for symbol {1}", t.ActorRef, TickerSymbol); 178 | } 179 | }); 180 | 181 | Command(s => 182 | { 183 | Sender.Tell(_matchingEngine.GetSnapshot()); 184 | }); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Service/Akka.CQRS.TradeProcessor.Service.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | $(NetVersion) 5 | 6 | 7 | 8 | Always 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS base 2 | WORKDIR /app 3 | 4 | # should be a comma-delimited list 5 | ENV CLUSTER_SEEDS "[]" 6 | ENV CLUSTER_IP "" 7 | ENV CLUSTER_PORT "5055" 8 | ENV SQL_CONNECTION_STR "" # SQL connection string for Akka.Persistence.Sql 9 | ENV SQL_PROVIDER_NAME "" # SQL provider for Akka.Persistence.Sql 10 | 11 | COPY ./bin/Release/net7.0/publish/ /app 12 | 13 | # 9110 - Petabridge.Cmd 14 | # 5055 - Akka.Cluster 15 | EXPOSE 9110 5055 16 | 17 | # Install Petabridge.Cmd client 18 | RUN dotnet tool install --global pbm 19 | 20 | # Needed because https://stackoverflow.com/questions/51977474/install-dotnet-core-tool-dockerfile 21 | ENV PATH="${PATH}:/root/.dotnet/tools" 22 | 23 | # RUN pbm help 24 | 25 | CMD ["dotnet", "Akka.CQRS.TradeProcessor.Service.dll"] -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Service/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Akka.Bootstrap.Docker; 5 | using Akka.Cluster.Sharding; 6 | using Akka.Cluster.Tools.PublishSubscribe; 7 | using Akka.Configuration; 8 | using Akka.CQRS.Infrastructure.Ops; 9 | using Akka.Hosting; 10 | using Akka.Persistence.Sql; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.Extensions.Logging; 13 | using Petabridge.Cmd.Cluster; 14 | using Petabridge.Cmd.Cluster.Sharding; 15 | using Petabridge.Cmd.Host; 16 | using Petabridge.Cmd.Remote; 17 | using static Akka.CQRS.Infrastructure.SqlDbHoconHelper; 18 | using Akka.CQRS.TradeProcessor.Actors; 19 | using Akka.CQRS.Infrastructure; 20 | 21 | namespace Akka.CQRS.TradeProcessor.Service 22 | { 23 | public static class Program 24 | { 25 | public static async Task Main(string[] args) 26 | { 27 | var sqlConnectionString = Environment.GetEnvironmentVariable("SQL_CONNECTION_STR")?.Trim(); 28 | if (string.IsNullOrEmpty(sqlConnectionString)) 29 | { 30 | Console.WriteLine("ERROR! SQL connection string not provided. Can't start."); 31 | return -1; 32 | } 33 | Console.WriteLine($"Connecting to SQL server at {sqlConnectionString}"); 34 | 35 | var sqlProviderName = Environment.GetEnvironmentVariable("SQL_PROVIDER_NAME")?.Trim(); 36 | if (string.IsNullOrEmpty(sqlProviderName)) 37 | { 38 | Console.WriteLine("ERROR! SQL provider name not provided. Can't start."); 39 | return -1; 40 | } 41 | Console.WriteLine($"Connecting to SQL provider {sqlProviderName}"); 42 | 43 | // Need to wait for the SQL server to spin up 44 | await Task.Delay(TimeSpan.FromSeconds(15)); 45 | 46 | var config = await File.ReadAllTextAsync("app.conf"); 47 | 48 | using var host = new HostBuilder() 49 | .ConfigureServices((hostContext, services) => 50 | { 51 | 52 | services.AddAkka("AkkaTrader", options => 53 | { 54 | // Add HOCON configuration from Docker 55 | var conf = ConfigurationFactory.ParseString(config) 56 | .WithFallback(GetSqlHocon(sqlConnectionString, sqlProviderName)) 57 | .WithFallback(OpsConfig.GetOpsConfig()) 58 | .WithFallback(ClusterSharding.DefaultConfig()) 59 | .WithFallback(DistributedPubSub.DefaultConfig()) 60 | .WithFallback(SqlPersistence.DefaultConfiguration); 61 | options.AddHocon(conf.BootstrapFromDocker(), HoconAddMode.Prepend) 62 | .WithActors((system, registry) => 63 | { 64 | Cluster.Cluster.Get(system).RegisterOnMemberUp(() => 65 | { 66 | var sharding = ClusterSharding.Get(system); 67 | 68 | var shardRegion = sharding.Start("orderBook", s => OrderBookActor.PropsFor(s), ClusterShardingSettings.Create(system), 69 | new StockShardMsgRouter()); 70 | }); 71 | }) 72 | .AddPetabridgeCmd(cmd => 73 | { 74 | Console.WriteLine(" PetabridgeCmd Added"); 75 | cmd.RegisterCommandPalette(ClusterCommands.Instance); 76 | cmd.RegisterCommandPalette(ClusterShardingCommands.Instance); 77 | cmd.RegisterCommandPalette(new RemoteCommands()); 78 | cmd.Start(); 79 | }); 80 | 81 | }); 82 | }) 83 | .ConfigureLogging((hostContext, configLogging) => 84 | { 85 | configLogging.AddConsole(); 86 | }) 87 | .UseConsoleLifetime() 88 | .Build(); 89 | await host.RunAsync(); 90 | Console.ReadLine(); 91 | return 0; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Akka.CQRS.TradeProcessor.Service/app.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | actor { 3 | provider = cluster 4 | } 5 | 6 | remote { 7 | dot-netty.tcp { 8 | hostname = "127.0.0.1" 9 | port = 5055 10 | } 11 | } 12 | 13 | cluster { 14 | #will inject this node as a self-seed node at run-time 15 | seed-nodes = ["akka.tcp://AkkaTrader@127.0.0.1:5055"] 16 | roles = ["trade-processor" , "trade-events"] 17 | 18 | pub-sub{ 19 | role = "trade-events" 20 | } 21 | 22 | sharding{ 23 | role = "trade-processor" 24 | } 25 | } 26 | 27 | persistence{ 28 | journal { 29 | plugin = "akka.persistence.journal.sql" 30 | sql { 31 | event-adapters { 32 | stock-tagger = "Akka.CQRS.Infrastructure.StockEventTagger, Akka.CQRS.Infrastructure" 33 | } 34 | event-adapter-bindings { 35 | "Akka.CQRS.IWithStockId, Akka.CQRS" = stock-tagger 36 | } 37 | } 38 | } 39 | 40 | snapshot-store { 41 | plugin = "akka.persistence.snapshot-store.sql" 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/Akka.CQRS.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | $(NetStandardVersion) 4 | Core messages, domain events, and infrastructure. 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Akka.CQRS/AvailableTickerSymbols.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS 6 | { 7 | /// 8 | /// The set of available ticker symbols for this demo. 9 | /// 10 | public static class AvailableTickerSymbols 11 | { 12 | public static readonly string[] Symbols = { "MSFT", "AMZN", "GOOG", "TSLA", "TEAM", "AMD", "WDC", "STX", "UBER", "SNAP", "FB" }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Commands/GetOrderBookSnapshot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Commands 6 | { 7 | /// 8 | /// Specifics the level of detail for an OrderBook snapshot. 9 | /// 10 | public enum DetailLevel 11 | { 12 | /// 13 | /// Lists all of the details 14 | /// 15 | Full, 16 | 17 | /// 18 | /// Lists only the aggregates 19 | /// 20 | Summary 21 | } 22 | 23 | /// 24 | /// 25 | /// Query the current order book snapshot 26 | /// 27 | public class GetOrderBookSnapshot : IWithStockId 28 | { 29 | public GetOrderBookSnapshot(string stockId, DetailLevel detail = DetailLevel.Summary) 30 | { 31 | StockId = stockId; 32 | Detail = detail; 33 | } 34 | 35 | public string StockId { get; } 36 | 37 | public DetailLevel Detail { get; } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Commands/GetRecentMatches.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Commands 6 | { 7 | /// 8 | /// Query an order book for the set of recent matches 9 | /// 10 | public sealed class GetRecentMatches : IWithStockId 11 | { 12 | public GetRecentMatches(string stockId) 13 | { 14 | StockId = stockId; 15 | } 16 | 17 | public string StockId { get; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Entities/Order.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | using System.Collections.Immutable; 9 | using System.Linq; 10 | using Akka.CQRS.Events; 11 | 12 | namespace Akka.CQRS 13 | { 14 | /// 15 | /// Represents an unfilled or partially unfilled trade inside the matching engine. 16 | /// 17 | public struct Order : IWithOrderId, IWithStockId, IEquatable, IComparable, IComparable 18 | { 19 | /// 20 | /// Represents an empty or completed trade. 21 | /// 22 | public static readonly Order Empty = new Order(string.Empty, string.Empty, TradeSide.Buy, 0.0D, 0.0m, DateTimeOffset.MinValue); 23 | 24 | /// 25 | /// Used to validating that orders have been totally filled using floating-point precision. 26 | /// 27 | public const double Epsilon = 0.001d; 28 | 29 | public Order(string tradeId, string stockId, TradeSide side, double originalQuantity, decimal price, DateTimeOffset timeIssued) 30 | : this(tradeId, stockId, side, originalQuantity, price, timeIssued, ImmutableList.Create()) 31 | { 32 | } 33 | 34 | public Order(string tradeId, string stockId, TradeSide side, double originalQuantity, decimal price, DateTimeOffset timeIssued, IImmutableList fills) 35 | { 36 | OrderId = tradeId; 37 | StockId = stockId; 38 | Side = side; 39 | OriginalQuantity = originalQuantity; 40 | Price = price; 41 | TimeIssued = timeIssued; 42 | Fills = fills; 43 | } 44 | 45 | public string OrderId { get; } 46 | public string StockId { get; } 47 | 48 | public TradeSide Side { get; } 49 | 50 | public double OriginalQuantity { get; } 51 | 52 | public double RemainingQuantity => OriginalQuantity - Fills.Sum(x => x.Quantity); 53 | 54 | public decimal Price { get; } 55 | 56 | public DateTimeOffset TimeIssued { get; } 57 | 58 | public bool Completed => Math.Abs(Fills.Sum(x => x.Quantity) - OriginalQuantity) < Epsilon; 59 | 60 | public IImmutableList Fills { get; } 61 | 62 | public Order WithFill(Fill fill) 63 | { 64 | // validate that the right fill event was sent to the right trade 65 | if (!fill.OrderId.Equals(OrderId)) 66 | { 67 | throw new ArgumentException($"Expected fill for tradeId {OrderId}, but instead received one for {fill.OrderId}"); 68 | } 69 | 70 | return new Order(OrderId, StockId, Side, OriginalQuantity, Price, TimeIssued, Fills.Add(fill)); 71 | } 72 | 73 | public bool Match(Order opposite) 74 | { 75 | if(Side == opposite.Side) 76 | throw new InvalidOperationException($"Can't match order {OrderId} with {opposite.OrderId} for {StockId} - both trades are on the [{Side}] side!"); 77 | 78 | switch (Side) 79 | { 80 | case TradeSide.Buy: 81 | return opposite.Price <= Price; 82 | case TradeSide.Sell: 83 | return opposite.Price >= Price; 84 | default: 85 | throw new ArgumentException($"Unrecognized TradeSide option [{Side}]"); 86 | } 87 | } 88 | 89 | public bool Equals(Order other) 90 | { 91 | return string.Equals(OrderId, other.OrderId); 92 | } 93 | 94 | public override bool Equals(object obj) 95 | { 96 | if (ReferenceEquals(null, obj)) return false; 97 | return obj is Order other && Equals(other); 98 | } 99 | 100 | public override int GetHashCode() 101 | { 102 | return OrderId.GetHashCode(); 103 | } 104 | 105 | public static bool operator ==(Order left, Order right) 106 | { 107 | return left.Equals(right); 108 | } 109 | 110 | public static bool operator !=(Order left, Order right) 111 | { 112 | return !left.Equals(right); 113 | } 114 | 115 | public int CompareTo(Order other) 116 | { 117 | return Price.CompareTo(other.Price); 118 | } 119 | 120 | public int CompareTo(object obj) 121 | { 122 | if (ReferenceEquals(null, obj)) return 1; 123 | return obj is Order other ? CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(Order)}"); 124 | } 125 | 126 | public static bool operator <(Order left, Order right) 127 | { 128 | return left.CompareTo(right) < 0; 129 | } 130 | 131 | public static bool operator >(Order left, Order right) 132 | { 133 | return left.CompareTo(right) > 0; 134 | } 135 | 136 | public static bool operator <=(Order left, Order right) 137 | { 138 | return left.CompareTo(right) <= 0; 139 | } 140 | 141 | public static bool operator >=(Order left, Order right) 142 | { 143 | return left.CompareTo(right) >= 0; 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/Entities/OrderExtensions.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System.Collections.Generic; 8 | using Akka.CQRS.Events; 9 | 10 | namespace Akka.CQRS 11 | { 12 | /// 13 | /// Sorts open orders by their price. 14 | /// 15 | public sealed class OrderPriceComparer : IComparer 16 | { 17 | public static readonly OrderPriceComparer Instance = new OrderPriceComparer(); 18 | 19 | private OrderPriceComparer() { } 20 | 21 | public int Compare(Order x, Order y) 22 | { 23 | if (x.Price.Equals(y.Price)) 24 | return 0; 25 | if (x.Price < y.Price) 26 | return -1; 27 | return 1; 28 | } 29 | } 30 | 31 | /// 32 | /// Extension methods for working with , , and . 33 | /// 34 | public static class OrderExtensions 35 | { 36 | public static Order ToOrder(this Bid bid) 37 | { 38 | return new Order(bid.OrderId, bid.StockId, TradeSide.Buy, bid.BidQuantity, bid.BidPrice, bid.TimeIssued); 39 | } 40 | 41 | public static Order ToOrder(this Ask ask) 42 | { 43 | return new Order(ask.OrderId, ask.StockId, TradeSide.Sell, ask.AskQuantity, ask.AskPrice, ask.TimeIssued); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/EntityIdHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS 6 | { 7 | /// 8 | /// Utility class for naming some of our persistent entities 9 | /// 10 | public static class EntityIdHelper 11 | { 12 | public const string OrderBookSuffix = "-orderBook"; 13 | public const string PriceSuffix = "-prices"; 14 | 15 | public static string IdForOrderBook(string tickerSymbol) 16 | { 17 | return tickerSymbol + OrderBookSuffix; 18 | } 19 | 20 | public static string ExtractTickerFromPersistenceId(string persistenceId) 21 | { 22 | return persistenceId.Split('-')[0]; 23 | } 24 | 25 | public static string IdForPricing(string tickerSymbol) 26 | { 27 | return tickerSymbol + PriceSuffix; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Events/Ask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Akka.CQRS.Events 4 | { 5 | /// 6 | /// 7 | /// Represents a "sell"-side event 8 | /// 9 | public sealed class Ask : IWithStockId, IWithOrderId 10 | { 11 | public Ask(string stockId, string orderId, decimal askPrice, 12 | double askQuantity, DateTimeOffset timeIssued) 13 | { 14 | StockId = stockId; 15 | AskPrice = askPrice; 16 | AskQuantity = askQuantity; 17 | TimeIssued = timeIssued; 18 | OrderId = orderId; 19 | } 20 | 21 | public string StockId { get; } 22 | 23 | public decimal AskPrice { get; } 24 | 25 | public double AskQuantity { get; } 26 | 27 | public DateTimeOffset TimeIssued { get; } 28 | public string OrderId { get; } 29 | } 30 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/Events/Bid.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Events 6 | { 7 | /// 8 | /// 9 | /// Represents a "buy"-side event 10 | /// 11 | public sealed class Bid : IWithStockId, IWithOrderId 12 | { 13 | public Bid(string stockId, string orderId, decimal bidPrice, 14 | double bidQuantity, DateTimeOffset timeIssued) 15 | { 16 | StockId = stockId; 17 | BidPrice = bidPrice; 18 | BidQuantity = bidQuantity; 19 | TimeIssued = timeIssued; 20 | OrderId = orderId; 21 | } 22 | 23 | public string StockId { get; } 24 | 25 | public decimal BidPrice { get; } 26 | 27 | public double BidQuantity { get; } 28 | 29 | public DateTimeOffset TimeIssued { get; } 30 | public string OrderId { get; } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Events/Fill.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Events 6 | { 7 | /// 8 | /// Fill an open order 9 | /// 10 | public sealed class Fill : IWithOrderId, IWithStockId 11 | { 12 | public Fill(string orderId, string stockId, double quantity, decimal price, 13 | string filledById, DateTimeOffset timestamp, bool partialFill = false) 14 | { 15 | OrderId = orderId; 16 | Quantity = quantity; 17 | Price = price; 18 | FilledById = filledById; 19 | Timestamp = timestamp; 20 | StockId = stockId; 21 | Partial = partialFill; 22 | } 23 | 24 | public string OrderId { get; } 25 | 26 | public double Quantity { get; } 27 | 28 | public decimal Price { get; } 29 | 30 | public string FilledById { get; } 31 | 32 | public DateTimeOffset Timestamp { get; } 33 | 34 | /// 35 | /// When true, indicates that the order was only partially filled. 36 | /// 37 | public bool Partial { get; } 38 | 39 | public string StockId { get; } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Events/Match.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS.Events 6 | { 7 | /// 8 | /// Matches a buy / sell-side order 9 | /// 10 | public sealed class Match : IWithStockId, ITradeEvent, IEquatable 11 | { 12 | public Match(string stockId, string buyOrderId, string sellOrderId, decimal settlementPrice, double quantity, DateTimeOffset timeStamp) 13 | { 14 | StockId = stockId; 15 | SettlementPrice = settlementPrice; 16 | Quantity = quantity; 17 | TimeStamp = timeStamp; 18 | BuyOrderId = buyOrderId; 19 | SellOrderId = sellOrderId; 20 | } 21 | 22 | public string StockId { get; } 23 | 24 | public string BuyOrderId { get; } 25 | 26 | public string SellOrderId { get; } 27 | 28 | public decimal SettlementPrice { get; } 29 | 30 | public double Quantity { get; } 31 | 32 | public DateTimeOffset TimeStamp { get; } 33 | 34 | public bool Equals(Match other) 35 | { 36 | if (ReferenceEquals(null, other)) return false; 37 | if (ReferenceEquals(this, other)) return true; 38 | return string.Equals(StockId, other.StockId) 39 | && string.Equals(BuyOrderId, other.BuyOrderId) 40 | && string.Equals(SellOrderId, other.SellOrderId); 41 | } 42 | 43 | public override bool Equals(object obj) 44 | { 45 | if (ReferenceEquals(null, obj)) return false; 46 | if (ReferenceEquals(this, obj)) return true; 47 | return obj is Match other && Equals(other); 48 | } 49 | 50 | public override int GetHashCode() 51 | { 52 | unchecked 53 | { 54 | var hashCode = StockId.GetHashCode(); 55 | hashCode = (hashCode * 397) ^ BuyOrderId.GetHashCode(); 56 | hashCode = (hashCode * 397) ^ SellOrderId.GetHashCode(); 57 | return hashCode; 58 | } 59 | } 60 | 61 | public static bool operator ==(Match left, Match right) 62 | { 63 | return Equals(left, right); 64 | } 65 | 66 | public static bool operator !=(Match left, Match right) 67 | { 68 | return !Equals(left, right); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Akka.CQRS/ITimestamper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS 6 | { 7 | /// 8 | /// Produces records for trades and orders. 9 | /// 10 | public interface ITimestamper 11 | { 12 | DateTimeOffset Now { get; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Akka.CQRS/ITradeEvent.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | namespace Akka.CQRS 7 | { 8 | /// 9 | /// Marker interfaces for trade activity 10 | /// 11 | public interface ITradeEvent 12 | { 13 | 14 | } 15 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/ITradeOrderGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | 4 | namespace Akka.CQRS 5 | { 6 | /// 7 | /// Generates unique trade order Ids. 8 | /// 9 | public interface ITradeOrderIdGenerator 10 | { 11 | string NextId(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Akka.CQRS/IWithOrderId.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | namespace Akka.CQRS 7 | { 8 | /// 9 | /// Marker interface for routing messages with specific trade IDs 10 | /// 11 | public interface IWithOrderId : ITradeEvent 12 | { 13 | /// 14 | /// Unique identifier for a specific order 15 | /// 16 | string OrderId { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/IWithStockId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS 6 | { 7 | /// 8 | /// Marker interface used for routing messages for specific stock IDs 9 | /// 10 | public interface IWithStockId 11 | { 12 | /// 13 | /// The ticker symbol for a specific stock. 14 | /// 15 | string StockId { get; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Akka.CQRS/OrderbookSnapshot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Akka.CQRS.Events; 5 | 6 | namespace Akka.CQRS 7 | { 8 | /// 9 | /// Indicates which side of the trade this transaction occurred on. 10 | /// 11 | public enum TradeSide 12 | { 13 | Buy, 14 | Sell 15 | } 16 | 17 | /// 18 | /// The full state of the current order book for a given . 19 | /// 20 | public sealed class OrderbookSnapshot : IWithStockId 21 | { 22 | public OrderbookSnapshot(string stockId, DateTimeOffset timestamp, double askQuantity, double bidQuantity, IReadOnlyCollection asks, IReadOnlyCollection bids) 23 | { 24 | StockId = stockId; 25 | Timestamp = timestamp; 26 | AskQuantity = askQuantity; 27 | BidQuantity = bidQuantity; 28 | Asks = asks; 29 | Bids = bids; 30 | } 31 | 32 | public string StockId { get; } 33 | 34 | public DateTimeOffset Timestamp { get; } 35 | 36 | public double AskQuantity { get; } 37 | 38 | public double BidQuantity { get; } 39 | 40 | public IReadOnlyCollection Asks { get; } 41 | 42 | public IReadOnlyCollection Bids { get; } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Akka.CQRS/PriceRange.cs: -------------------------------------------------------------------------------- 1 | namespace Akka.CQRS 2 | { 3 | /// 4 | /// Represents a price band, typically weighted by buy/sell volume. 5 | /// 6 | public struct PriceRange 7 | { 8 | public PriceRange(decimal min, decimal mean, decimal max) 9 | { 10 | Min = min; 11 | Mean = mean; 12 | Max = max; 13 | } 14 | 15 | public decimal Min { get; } 16 | 17 | public decimal Mean { get; } 18 | 19 | public decimal Max { get; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/PriceRangeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Akka.CQRS 6 | { 7 | /// 8 | /// Utility class for helping generate values 9 | /// within a . 10 | /// 11 | public static class PriceRangeExtensions 12 | { 13 | public static decimal WithinRange(this Random r, PriceRange range) 14 | { 15 | var sample = NextDecimalSample(r); 16 | return range.Max * sample + range.Min * (1 - sample); 17 | } 18 | 19 | /* 20 | * Random RNG algorithms provided by Jon Skeet's answer: https://stackoverflow.com/a/609529/377476 21 | * And by Bryan Loeper's answer: https://stackoverflow.com/a/28860710/377476 22 | * 23 | */ 24 | public static int NextInt32(this Random rng) 25 | { 26 | var firstBits = rng.Next(0, 1 << 4) << 28; 27 | var lastBits = rng.Next(0, 1 << 28); 28 | return firstBits | lastBits; 29 | } 30 | 31 | public static decimal NextDecimalSample(this Random random) 32 | { 33 | var sample = 1m; 34 | //After ~200 million tries this never took more than one attempt but it is possible to generate combinations of a, b, and c with the approach below resulting in a sample >= 1. 35 | while (sample >= 1) 36 | { 37 | var a = random.NextInt32(); 38 | var b = random.NextInt32(); 39 | //The high bits of 0.9999999999999999999999999999m are 542101086. 40 | var c = random.Next(542101087); 41 | sample = new decimal(a, b, c, false, 28); 42 | } 43 | return sample; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Akka.CQRS/Util/CurrentUtcTimestamper.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | 9 | namespace Akka.CQRS.Util 10 | { 11 | /// 12 | /// 13 | /// Uses to provide timestamp signatures. 14 | /// 15 | public sealed class CurrentUtcTimestamper : ITimestamper 16 | { 17 | public static readonly CurrentUtcTimestamper Instance = new CurrentUtcTimestamper(); 18 | private CurrentUtcTimestamper() { } 19 | public DateTimeOffset Now => DateTimeOffset.UtcNow; 20 | } 21 | } -------------------------------------------------------------------------------- /src/Akka.CQRS/Util/GuidTradeOrderIdGenerator.cs: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------- 2 | // 3 | // Copyright (C) 2015 - 2019 Petabridge, LLC 4 | // 5 | // ----------------------------------------------------------------------- 6 | 7 | using System; 8 | 9 | namespace Akka.CQRS.Util 10 | { 11 | /// 12 | /// Creates trade order ids using s. 13 | /// 14 | public sealed class GuidTradeOrderIdGenerator : ITradeOrderIdGenerator 15 | { 16 | public static readonly GuidTradeOrderIdGenerator Instance = new GuidTradeOrderIdGenerator(); 17 | private GuidTradeOrderIdGenerator() { } 18 | 19 | public string NextId() 20 | { 21 | return Guid.NewGuid().ToString(); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | Copyright © 2017 Your Company 4 | Your Authors 5 | 0.1.0 6 | First release 7 | 8 | 9 | 10 | 11 | 12 | 13 | $(NoWarn);CS1591 14 | 15 | 16 | 1.5.8 17 | 1.5.13 18 | 1.5.2-beta3 19 | 1.5.1 20 | 1.3.1 21 | 1.5.2 22 | 0.5.3 23 | 5.1.1 24 | 2.0.1 25 | 1.2.2 26 | 2.6.2 27 | 2.4.5 28 | 17.6.3 29 | 6.11.0 30 | 31 | 32 | net6.0 33 | net7.0 34 | netstandard2.0 35 | 36 | -------------------------------------------------------------------------------- /src/Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | --------------------------------------------------------------------------------