diff --git a/Cargo.lock b/Cargo.lock index 8f158098..27ea4389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,21 @@ dependencies = [ "typenum", ] +[[package]] +name = "custom-fields" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "hotfix", + "hotfix-codegen", + "hotfix-dictionary", + "hotfix-message", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "darling" version = "0.21.3" diff --git a/dummy-executor/cmd/executor.go b/dummy-executor/cmd/executor.go index 91a798b2..4587d92a 100644 --- a/dummy-executor/cmd/executor.go +++ b/dummy-executor/cmd/executor.go @@ -109,6 +109,14 @@ func (e *Executor) onNewOrderSingle(msg newordersingle.NewOrderSingle, sessionID log.Printf("Received NewOrderSingle: ClOrdID=%s Symbol=%s Side=%s Qty=%s", clOrdID, symbol, string(side), orderQty.String()) + // Read the optional custom tag (6001 = ClientStrategyId). + var clientStrategyID quickfix.FIXInt + hasClientStrategyID := false + if err := msg.Body.GetField(quickfix.Tag(6001), &clientStrategyID); err == nil { + hasClientStrategyID = true + log.Printf(" ClientStrategyId=%d", int(clientStrategyID)) + } + // Look up FX rate; default to 1.0000 for unknown pairs. price, ok := fxRates[symbol] if !ok { @@ -133,6 +141,9 @@ func (e *Executor) onNewOrderSingle(msg newordersingle.NewOrderSingle, sessionID ack.Set(field.NewClOrdID(clOrdID)) ack.Set(field.NewSymbol(symbol)) ack.Set(field.NewOrderQty(orderQty, 2)) + if hasClientStrategyID { + ack.Body.SetField(quickfix.Tag(6001), clientStrategyID) + } if sendErr := quickfix.SendToTarget(ack.ToMessage(), sessionID); sendErr != nil { log.Printf("Error sending ACK: %v", sendErr) @@ -156,6 +167,9 @@ func (e *Executor) onNewOrderSingle(msg newordersingle.NewOrderSingle, sessionID fill.Set(field.NewOrderQty(orderQty, 2)) fill.Set(field.NewLastQty(orderQty, 2)) fill.Set(field.NewLastPx(price, 4)) + if hasClientStrategyID { + fill.Body.SetField(quickfix.Tag(6001), clientStrategyID) + } if sendErr := quickfix.SendToTarget(fill.ToMessage(), sessionID); sendErr != nil { log.Printf("Error sending FILL: %v", sendErr) diff --git a/examples/custom-fields/Cargo.toml b/examples/custom-fields/Cargo.toml new file mode 100644 index 00000000..e75492a8 --- /dev/null +++ b/examples/custom-fields/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "custom-fields" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +hotfix = { path = "../../crates/hotfix" } +hotfix-message = { path = "../../crates/hotfix-message" } + +anyhow.workspace = true +async-trait.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[build-dependencies] +hotfix-codegen = { path = "../../crates/hotfix-codegen" } +hotfix-dictionary = { path = "../../crates/hotfix-dictionary" } diff --git a/examples/custom-fields/README.md b/examples/custom-fields/README.md new file mode 100644 index 00000000..31f562d1 --- /dev/null +++ b/examples/custom-fields/README.md @@ -0,0 +1,62 @@ +# Custom Fields — using a custom XML dictionary + +This example demonstrates how to use a QuickFIX-style XML dictionary that +extends the bundled FIX 4.4 spec with your own custom fields, exercising both +sides of HotFIX's custom-XML support: + +- **Build-time codegen** — `build.rs` runs `hotfix-codegen` against + `spec/FIX44-custom.xml` to produce typed field constants under a + `custom_fix` module (e.g. `custom_fix::CLIENT_STRATEGY_ID`). +- **Runtime dictionary validation** — the session loads the same XML at + startup via `data_dictionary_path` and uses it to validate inbound and + outbound messages. + +The example sends a `NewOrderSingle (D)` carrying `ClientStrategyId=42` +and expects the dummy executor to echo the field on the resulting +`ExecutionReport`s. If the field doesn't round-trip, the example exits +non-zero with a descriptive error. + +## The custom XML + +`spec/FIX44-custom.xml` is a verbatim copy of the bundled +`crates/hotfix-dictionary/src/resources/quickfix/FIX-4.4.xml` with one +addition: a `` +in the `` block, plus an optional reference to it on +`NewOrderSingle` and `ExecutionReport`. + +## Using the generated constants + +All field constants and typed enums (`Side`, `OrdType`, `OrdStatus`, …) come +from the `custom_fix` module — including the ones for standard FIX 4.4 +tags. This keeps the example aligned with a single source of truth: the +custom XML drives both compile-time typing and runtime validation. The +`hotfix::fix44` re-exports are deliberately not used here, so the +`hotfix` dependency in `Cargo.toml` doesn't enable the `fix44` feature. + +## Running the example + +In one terminal, build and start the dummy executor via the existing compose file: + +```shell +docker compose -f example.compose.yml up --build dummy-executor +``` + +In another, from the repo root, run the example: + +```shell +cargo run -p custom-fields +``` + +Expected log output: + +``` +INFO custom_fields: waiting for logon (up to 10s) +INFO custom_fields::application: logged on +INFO custom_fields: sending NewOrderSingle ClOrdID=demo-1 ClientStrategyId=42 +INFO custom_fields: received ExecutionReport ClOrdID=demo-1 OrdStatus=New ClientStrategyId=Some(42) +INFO custom_fields: received ExecutionReport ClOrdID=demo-1 OrdStatus=Filled ClientStrategyId=Some(42) +INFO custom_fields: order filled, custom field round-tripped successfully +INFO custom_fields: shutting down +``` + +The example should then exit. diff --git a/examples/custom-fields/build.rs b/examples/custom-fields/build.rs new file mode 100644 index 00000000..660260e8 --- /dev/null +++ b/examples/custom-fields/build.rs @@ -0,0 +1,29 @@ +use hotfix_codegen as codegen; +use hotfix_dictionary::Dictionary; +use std::env::var; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +fn main() -> std::io::Result<()> { + let spec_path = "spec/FIX44-custom.xml"; + println!("cargo:rerun-if-changed={spec_path}"); + + let dict = + Dictionary::load_from_file(spec_path).expect("failed to load custom FIX 4.4 dictionary"); + + let mut settings = codegen::Settings::default(); + // The generated code uses `::dict::FieldLocation`, `::FieldType`, + // and `::HardCodedFixFieldDefinition` — re-exported by `hotfix-message` + // but not by `hotfix`, so we point codegen at `hotfix_message`. + settings.hotfix_crate_name = "hotfix_message".to_string(); + + let code = codegen::gen_definitions(&dict, &settings); + + let out_dir = PathBuf::from(var("OUT_DIR").expect("OUT_DIR not set by cargo")); + let out_path = out_dir.join("custom_fix.rs"); + let mut file = File::create(&out_path)?; + file.write_all(code.as_bytes())?; + + Ok(()) +} diff --git a/examples/custom-fields/config/test-config.toml b/examples/custom-fields/config/test-config.toml new file mode 100644 index 00000000..de42b8f7 --- /dev/null +++ b/examples/custom-fields/config/test-config.toml @@ -0,0 +1,12 @@ +[[sessions]] +begin_string = "FIX.4.4" +sender_comp_id = "dummy-initiator" +target_comp_id = "dummy-acceptor" + +connection_port = 9880 +connection_host = "127.0.0.1" + +heartbeat_interval = 30 +reset_on_logon = true + +data_dictionary_path = "examples/custom-fields/spec/FIX44-custom.xml" diff --git a/examples/custom-fields/spec/FIX44-custom.xml b/examples/custom-fields/spec/FIX44-custom.xml new file mode 100644 index 00000000..e28f95fc --- /dev/null +++ b/examples/custom-fields/spec/FIX44-custom.xml @@ -0,0 +1,6596 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/examples/custom-fields/src/application.rs b/examples/custom-fields/src/application.rs new file mode 100644 index 00000000..96e619a9 --- /dev/null +++ b/examples/custom-fields/src/application.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use hotfix::Application; +use hotfix::Message; +use hotfix::application::{InboundDecision, OutboundDecision}; +use hotfix::message::Part; +use hotfix::session::Status; +use tokio::sync::{Notify, mpsc}; +use tracing::{info, warn}; + +use crate::custom_fix; +use crate::messages::{ExecReportSummary, OutboundMsg}; + +pub struct TestApplication { + pub logon_signal: Arc, + pub exec_tx: mpsc::UnboundedSender, +} + +#[async_trait::async_trait] +impl Application for TestApplication { + type Outbound = OutboundMsg; + + async fn on_outbound_message(&self, _msg: &OutboundMsg) -> OutboundDecision { + OutboundDecision::Send + } + + async fn on_inbound_message(&self, msg: &Message) -> InboundDecision { + let msg_type: Result<&str, _> = msg.header().get(custom_fix::MSG_TYPE); + if !matches!(msg_type, Ok("8")) { + return InboundDecision::Accept; + } + + let cl_ord_id: Result<&str, _> = msg.get(custom_fix::CL_ORD_ID); + let ord_status: Result = msg.get(custom_fix::ORD_STATUS); + let client_strategy_id: Option = msg.get(custom_fix::CLIENT_STRATEGY_ID).ok(); + + match (cl_ord_id, ord_status) { + (Ok(cl_ord_id), Ok(ord_status)) => { + let summary = ExecReportSummary { + cl_ord_id: cl_ord_id.to_string(), + ord_status, + client_strategy_id, + }; + if let Err(err) = self.exec_tx.send(summary) { + warn!("failed to forward execution report: {err}"); + } + } + _ => warn!("execution report missing ClOrdID or OrdStatus"), + } + + InboundDecision::Accept + } + + async fn on_logout(&mut self, reason: &str) { + info!("logged out: {reason}"); + } + + async fn on_logon(&mut self) { + info!("logged on"); + self.logon_signal.notify_one(); + } + + async fn on_state_change(&self, from: &Status, to: &Status) { + info!("session state changed: {from:?} -> {to:?}"); + } +} diff --git a/examples/custom-fields/src/custom_fix.rs b/examples/custom-fields/src/custom_fix.rs new file mode 100644 index 00000000..e4ff7107 --- /dev/null +++ b/examples/custom-fields/src/custom_fix.rs @@ -0,0 +1,8 @@ +//! Field constants generated at build time from `spec/FIX44-custom.xml`. +//! +//! See `build.rs` and the README for how this is produced. + +#![allow(dead_code)] +#![allow(clippy::all)] + +include!(concat!(env!("OUT_DIR"), "/custom_fix.rs")); diff --git a/examples/custom-fields/src/main.rs b/examples/custom-fields/src/main.rs new file mode 100644 index 00000000..9474a681 --- /dev/null +++ b/examples/custom-fields/src/main.rs @@ -0,0 +1,128 @@ +mod application; +mod custom_fix; +mod messages; + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use hotfix::config::Config; +use hotfix::field_types::Timestamp; +use hotfix::initiator::Initiator; +use hotfix::store::in_memory::InMemoryMessageStore; +use tokio::sync::{Notify, mpsc}; +use tokio::time::timeout; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +use crate::application::TestApplication; +use crate::messages::{ExecReportSummary, NewOrderSingle, OutboundMsg}; + +const CONFIG_PATH: &str = "examples/custom-fields/config/test-config.toml"; +const LOGON_TIMEOUT: Duration = Duration::from_secs(10); +const FILL_TIMEOUT: Duration = Duration::from_secs(10); +const STRATEGY_ID: i32 = 42; +const CL_ORD_ID: &str = "demo-1"; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let mut config = Config::load_from_path(CONFIG_PATH).context("failed to load config")?; + let session_config = config + .sessions + .pop() + .context("config must include a session")?; + + let logon_signal = Arc::new(Notify::new()); + let (exec_tx, mut exec_rx) = mpsc::unbounded_channel::(); + + let app = TestApplication { + logon_signal: logon_signal.clone(), + exec_tx, + }; + + let initiator: Initiator = + Initiator::start(session_config, app, InMemoryMessageStore::default()) + .await + .context("failed to start initiator")?; + + info!("waiting for logon (up to {:?})", LOGON_TIMEOUT); + timeout(LOGON_TIMEOUT, logon_signal.notified()) + .await + .map_err(|_| anyhow!("session did not log on within {LOGON_TIMEOUT:?}"))?; + + let order = NewOrderSingle { + cl_ord_id: CL_ORD_ID.to_string(), + symbol: "EUR/USD".to_string(), + side: custom_fix::Side::Buy, + order_qty: 100, + transact_time: Timestamp::utc_now(), + client_strategy_id: STRATEGY_ID, + }; + info!("sending NewOrderSingle ClOrdID={CL_ORD_ID} ClientStrategyId={STRATEGY_ID}"); + initiator + .send(OutboundMsg::NewOrderSingle(order)) + .await + .context("failed to send NewOrderSingle")?; + + let result = wait_for_fill(&mut exec_rx).await; + + info!("shutting down"); + if let Err(err) = initiator.shutdown(false).await { + error!("graceful shutdown failed: {err}"); + } + + result +} + +async fn wait_for_fill(exec_rx: &mut mpsc::UnboundedReceiver) -> Result<()> { + let deadline = tokio::time::Instant::now() + FILL_TIMEOUT; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(anyhow!( + "did not receive a Filled ExecutionReport within {FILL_TIMEOUT:?}" + )); + } + + let summary = match timeout(remaining, exec_rx.recv()).await { + Ok(Some(s)) => s, + Ok(None) => return Err(anyhow!("execution-report channel closed unexpectedly")), + Err(_) => { + return Err(anyhow!( + "did not receive a Filled ExecutionReport within {FILL_TIMEOUT:?}" + )); + } + }; + + info!( + "received ExecutionReport ClOrdID={} OrdStatus={:?} ClientStrategyId={:?}", + summary.cl_ord_id, summary.ord_status, summary.client_strategy_id, + ); + + let echoed = summary.client_strategy_id.ok_or_else(|| { + anyhow!( + "ExecutionReport for ClOrdID={} did not echo ClientStrategyId — \ + the acceptor likely doesn't know about tag 6001", + summary.cl_ord_id, + ) + })?; + + if echoed != STRATEGY_ID { + return Err(anyhow!( + "ExecutionReport ClientStrategyId mismatch: expected {STRATEGY_ID}, got {echoed}", + )); + } + + if matches!(summary.ord_status, custom_fix::OrdStatus::Filled) { + info!("order filled, custom field round-tripped successfully"); + return Ok(()); + } + } +} diff --git a/examples/custom-fields/src/messages.rs b/examples/custom-fields/src/messages.rs new file mode 100644 index 00000000..50a034fb --- /dev/null +++ b/examples/custom-fields/src/messages.rs @@ -0,0 +1,49 @@ +use hotfix::Message as HotfixMessage; +use hotfix::field_types::Timestamp; +use hotfix::message::{OutboundMessage, Part}; + +use crate::custom_fix; + +#[derive(Debug, Clone)] +pub struct NewOrderSingle { + pub cl_ord_id: String, + pub symbol: String, + pub side: custom_fix::Side, + pub order_qty: u32, + pub transact_time: Timestamp, + pub client_strategy_id: i32, +} + +#[derive(Debug, Clone)] +pub enum OutboundMsg { + NewOrderSingle(NewOrderSingle), +} + +#[derive(Debug, Clone)] +pub struct ExecReportSummary { + pub cl_ord_id: String, + pub ord_status: custom_fix::OrdStatus, + pub client_strategy_id: Option, +} + +impl OutboundMessage for OutboundMsg { + fn write(&self, msg: &mut HotfixMessage) { + match self { + OutboundMsg::NewOrderSingle(order) => { + msg.set(custom_fix::CL_ORD_ID, order.cl_ord_id.as_str()); + msg.set(custom_fix::SYMBOL, order.symbol.as_str()); + msg.set(custom_fix::SIDE, order.side); + msg.set(custom_fix::ORDER_QTY, order.order_qty); + msg.set(custom_fix::TRANSACT_TIME, order.transact_time.clone()); + msg.set(custom_fix::ORD_TYPE, custom_fix::OrdType::Market); + msg.set(custom_fix::CLIENT_STRATEGY_ID, order.client_strategy_id); + } + } + } + + fn message_type(&self) -> &str { + match self { + OutboundMsg::NewOrderSingle(_) => "D", + } + } +}