31 Commits

Author SHA1 Message Date
96bf917191 Quote cancel 2026-06-23 10:56:11 -03:00
298e9c39e3 Api key configuracion and quotes endpooint 2026-06-01 13:03:06 -03:00
7cc4a96a03 fix 2026-05-19 15:38:43 -03:00
1676909cbf fixes 2026-05-19 15:31:39 -03:00
d06433e0f5 add log 2026-05-19 15:15:54 -03:00
0f3ac0dd8d sending the messages by seqnum 2026-05-15 17:03:54 -03:00
4270284362 Add endpoint for all messages 2026-05-12 13:27:13 -03:00
6e46fde5d2 fixes 2026-05-11 14:27:27 -03:00
99c7f8ccb0 fixes 2026-05-11 12:34:55 -03:00
45fad9de6c generic json for fix messages 2026-05-07 17:37:17 -03:00
36b841fc66 pascal case 2026-05-06 14:11:50 -03:00
68238d309a adding endpoints 2026-05-06 11:56:12 -03:00
15a60bac92 handling errors 2026-05-05 15:34:01 -03:00
b58c8df905 fix log library 2026-03-26 12:05:18 -03:00
82d2e1b5f7 Persistance and recovery 2026-03-19 13:23:23 -03:00
51ef6e182d Documentation of 8.4 flow 2026-03-16 17:03:12 -03:00
e17675d973 Flow 8.4 list trading working 2026-03-16 12:44:52 -03:00
5f1d7038ac merging 2026-03-13 14:23:47 -03:00
710772b052 Add QuoteStatusReport and QuoteAck handlers 2026-03-13 14:20:38 -03:00
4e62548091 fix ids 2026-03-13 12:11:40 -03:00
fbcaac95f5 fixes in quotes 2026-03-13 11:35:37 -03:00
3998726100 respond automatically to quote requests 2026-03-12 17:10:31 -03:00
1f1c0afb9a QuoteRequest fix 2026-03-12 14:59:11 -03:00
d1aff0212e Merge pull request 'Add Quickfix library' (#1) from quickfix into develop
Reviewed-on: #1
2026-03-12 15:32:09 +00:00
0910b1e6c8 fixes 2026-03-12 10:23:19 -03:00
50c7f98c37 adding notifications 2026-03-11 12:40:24 -03:00
48373b6855 fix store logs 2026-03-10 17:38:47 -03:00
1d32854a09 fix logs 2026-03-10 17:16:51 -03:00
7e26addd80 improvement 2026-03-10 16:36:57 -03:00
5053bfa9af changing to initiator 2026-03-10 16:28:09 -03:00
557c04436d adding project logic 2026-03-09 16:26:58 -03:00
32 changed files with 2827 additions and 32 deletions

View File

@ -56,13 +56,16 @@ build: check-env swag vendor only-build # Build a native version. Set e=environm
only-build: check-env only-build: check-env
@echo "Building for $(e) environment..." @echo "Building for $(e) environment..."
env OUT_PATH=$(DEFAULT_OUT_PATH) tools/build.sh $(e) env OUT_PATH=$(DEFAULT_OUT_PATH) GOARCH=amd64 GOOS=linux tools/build.sh $(e)
linux-build: check-env swag # Build a linux version for prod environment. Set e=environment: prod, dev, demo, open-demo linux-build: check-env swag # Build a linux version for prod environment. Set e=environment: prod, dev, demo, open-demo
env OUT_PATH=$(DEFAULT_OUT_PATH) GOARCH=amd64 GOOS=linux tools/build.sh $(e) env OUT_PATH=$(DEFAULT_OUT_PATH) GOARCH=amd64 GOOS=linux tools/build.sh $(e)
deploy: check-env # Deploy to remote server. Set e=environment: prod, dev, demo, open-demo deploy: # Deploy to remote server. Set e=environment: prod, dev, demo, open-demo; s=serverName; i=instance; e.g. make deploy e=dev s=nonprodFix i=dpl
tools/deploy.sh $(e) make build e=$(e) && qscp build/out/distribution/qfixdpl.gz $(s):/home/quantex/qfixtb/dpl/
open-demo:
make deploy e=open-demo s=nonprodFix
fmt: download-versions # Apply the Go formatter to the code fmt: download-versions # Apply the Go formatter to the code
cd tools/check; unset GOPATH; GOBIN=$$PWD/../bin go install mvdan.cc/gofumpt@$(call get_version,gofumpt); cd tools/check; unset GOPATH; GOBIN=$$PWD/../bin go install mvdan.cc/gofumpt@$(call get_version,gofumpt);

5
docs/EndTrade.log Normal file
View File

@ -0,0 +1,5 @@
8=FIXT.1.1|9=861|35=R|34=413|49=TRADEWEB|52=20260508-16:51:56.674|56=BYMA_CORI_TEST_12345_DLRDPL|131=LST_20260508_BYMA_CORI_NY1600105.1_1|146=1|55=ARGENT 1.500 07/09/35|48=040114HT0|22=1|460=12|167=CORP|762=REGCORIINV|541=20350709|225=20200904|470=AR|223=1.5|106=ARGENTINE REPUBLIC|54=2|38=10000|64=20260511|15=USD|6110=Sov|60=20260508-16:51:56|423=1|44=-999999|236=-999999|5023=0.000010|66=NY1600105.1|6847=1|75=20260508|464=Y|20086=1|20074=Y|20075=Y|20077=[N/A]|20078=[N/A]|20079=300|20081=300|20090=0|20072=300|20098=0|5745=1|20073=RFQ|20076=Y|20156=Y|20130=20501720000|20138=HSB|561=1|562=1|20175=20210709|20223=American|22630=0|20265=LatAm|20227=Global|23068=TRWB|453=3|448=bymacust|447=C|452=3|802=3|523=BYMA CUST|803=2|523=NY|803=25|523=AR|803=4000|448=BYMA Customer|447=C|452=1|448=DTCC|447=C|452=4|454=1|455=US040114HT09|456=4|5114=2|5113=1|20169=Ca|5113=0|20169=CCC+|10=078
8=FIXT.1.1|9=203|35=AI|34=418|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:51:56.695|56=TRADEWEB|55=[N/A]|60=20260508-16:51:56.695|117=LST_20260508_BYMA_CORI_NY1600105.1_1|131=LST_20260508_BYMA_CORI_NY1600105.1_1|297=0|10=165
8=FIXT.1.1|9=292|35=AJ|34=414|49=TRADEWEB|52=20260508-16:52:05.343|56=BYMA_CORI_TEST_12345_DLRDPL|11=LST_20260508_BYMA_CORI_NY1600105.1_1|55=[N/A]|60=20260508-16:52:05|117=[N/A]|131=LST_20260508_BYMA_CORI_NY1600105.1_1|693=LST_20260508_BYMA_CORI_NY1600105.1_1_LISTEND|694=7|20086=1|20103=N/A|20110=NO|22636=Y|10=077
8=FIXT.1.1|9=211|35=AI|34=419|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:52:05.367|56=TRADEWEB|55=[N/A]|60=20260508-16:52:05.367|131=LST_20260508_BYMA_CORI_NY1600105.1_1|297=0|693=LST_20260508_BYMA_CORI_NY1600105.1_1_LISTEND|10=014

14
docs/Execution.log Normal file
View File

@ -0,0 +1,14 @@
8=FIXT.1.1|9=862|35=R|34=393|49=TRADEWEB|52=20260508-16:44:06.978|56=BYMA_CORI_TEST_12345_DLRDPL|131=LST_20260508_BYMA_CORI_NY1600095.1_1|146=1|55=ARGENT 1.500 07/09/35|48=040114HT0|22=1|460=12|167=CORP|762=REGCORIINV|541=20350709|225=20200904|470=AR|223=1.5|106=ARGENTINE REPUBLIC|54=2|38=850000|64=20260511|15=USD|6110=Sov|60=20260508-16:44:06|423=1|44=-999999|236=-999999|5023=0.000010|66=NY1600095.1|6847=1|75=20260508|464=Y|20086=1|20074=Y|20075=Y|20077=[N/A]|20078=[N/A]|20079=300|20081=300|20090=0|20072=300|20098=0|5745=1|20073=RFQ|20076=Y|20156=Y|20130=20501720000|20138=HSB|561=1|562=1|20175=20210709|20223=American|22630=0|20265=LatAm|20227=Global|23068=TRWB|453=3|448=bymacust|447=C|452=3|802=3|523=BYMA CUST|803=2|523=NY|803=25|523=AR|803=4000|448=BYMA Customer|447=C|452=1|448=DTCC|447=C|452=4|454=1|455=US040114HT09|456=4|5114=2|5113=1|20169=Ca|5113=0|20169=CCC+|10=163
8=FIXT.1.1|9=203|35=AI|34=398|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:44:07.018|56=TRADEWEB|55=[N/A]|60=20260508-16:44:07.018|117=LST_20260508_BYMA_CORI_NY1600095.1_1|131=LST_20260508_BYMA_CORI_NY1600095.1_1|297=0|10=162
8=FIXT.1.1|9=293|35=S|34=399|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:44:34.096|56=TRADEWEB|15=USD|22=1|38=850000|44=91.00000000|48=040114HT0|54=2|55=[N/A]|60=20260508-16:44:34.096|64=20260511|117=LST_20260508_BYMA_CORI_NY1600095.1_1|131=LST_20260508_BYMA_CORI_NY1600095.1_1|132=91.00000000|423=1|537=211|10=127
8=FIXT.1.1|9=191|35=CW|34=394|49=TRADEWEB|52=20260508-16:44:34.137|56=BYMA_CORI_TEST_12345_DLRDPL|60=20260508-16:44:34|117=LST_20260508_BYMA_CORI_NY1600095.1_1|131=LST_20260508_BYMA_CORI_NY1600095.1_1|1865=1|10=004
8=FIXT.1.1|9=80|35=0|34=400|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:45:04.125|56=TRADEWEB|10=169
8=FIXT.1.1|9=80|35=0|34=395|49=TRADEWEB|52=20260508-16:45:04.142|56=BYMA_CORI_TEST_12345_DLRDPL|10=181
8=FIXT.1.1|9=391|35=AJ|34=396|49=TRADEWEB|52=20260508-16:45:10.511|56=BYMA_CORI_TEST_12345_DLRDPL|11=LST_20260508_BYMA_CORI_NY1600095.1_1|22=1|38=850000|44=91|48=040114HT0|54=2|55=[N/A]|60=20260508-16:45:10|117=LST_20260508_BYMA_CORI_NY1600095.1_1|131=LST_20260508_BYMA_CORI_NY1600095.1_1|423=1|693=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDREQ|694=1|20074=N|20075=N|20076=N|20079=236|20082=60|20156=N|22630=0|10=127
8=FIXT.1.1|9=295|35=8|34=397|49=TRADEWEB|52=20260508-16:45:10.514|56=BYMA_CORI_TEST_12345_DLRDPL|6=0|11=LST_20260508_BYMA_CORI_NY1600095.1_1|14=0|17=LST_20260508_BYMA_CORI_NY1600095.1_1_LISTEND-124510.514|37=LST_20260508_BYMA_CORI_NY1600095.1_1|39=A|55=[N/A]|60=20260508-16:45:10|75=20260508|150=A|151=0|20086=1|10=213
8=FIXT.1.1|9=210|35=AI|34=401|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:45:10.539|56=TRADEWEB|55=[N/A]|60=20260508-16:45:10.539|131=LST_20260508_BYMA_CORI_NY1600095.1_1|297=0|693=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDREQ|10=209
8=FIXT.1.1|9=261|35=BN|34=402|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:45:10.601|56=TRADEWEB|11=LST_20260508_BYMA_CORI_NY1600095.1_1|17=LST_20260508_BYMA_CORI_NY1600095.1_1_LISTEND-124510.514|37=LST_20260508_BYMA_CORI_NY1600095.1_1|55=[N/A]|60=20260508-16:45:10.601|1036=1|10=232
8=FIXT.1.1|9=303|35=8|34=398|49=TRADEWEB|52=20260508-16:45:10.666|56=BYMA_CORI_TEST_12345_DLRDPL|6=0|11=LST_20260508_BYMA_CORI_NY1600095.1_1|14=0|17=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDEND-124510.660|37=LST_20260508_BYMA_CORI_NY1600095.1_1|39=2|44=91|54=2|55=[N/A]|60=20260508-16:45:10|75=20260508|150=F|151=0|423=1|10=252
8=FIXT.1.1|9=260|35=BN|34=403|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:45:10.690|56=TRADEWEB|11=LST_20260508_BYMA_CORI_NY1600095.1_1|17=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDEND-124510.660|37=LST_20260508_BYMA_CORI_NY1600095.1_1|55=[N/A]|60=20260508-16:45:10.690|1036=1|10=168
8=FIXT.1.1|9=729|35=8|34=399|49=TRADEWEB|52=20260508-16:45:10.738|56=BYMA_CORI_TEST_12345_DLRDPL|6=0|11=LST_20260508_BYMA_CORI_NY1600095.1_1|14=0|17=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDSUMM-124510.736|22=1|37=LST_20260508_BYMA_CORI_NY1600095.1_1|38=850000|39=2|44=91|48=040114HT0|54=2|55=[N/A]|60=20260508-16:45:10.535|64=20260511|75=20260508|150=F|151=0|167=CORP|236=2.61081622|423=1|453=2|448=bymacust|447=C|452=3|802=3|523=BYMA CUST|803=2|523=NY|803=25|523=AR|803=4000|448=BYMA Customer|447=C|452=1|802=1|523=YES|803=4003|454=1|455=US040114HT09|456=4|526=TRD_20260508_BYMA_CORI_213|1003=20260508.BYMA.CORI.213|1907=1|1903=20260508BYMACORI213|1906=5|6731=20260508.BYMA.CORI.213|20115=91|22630=0|22636=Y|23068=TRWB|23096=20260508BYMACORI213|10=051
8=FIXT.1.1|9=261|35=BN|34=404|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:45:10.781|56=TRADEWEB|11=LST_20260508_BYMA_CORI_NY1600095.1_1|17=LST_20260508_BYMA_CORI_NY1600095.1_1_TRDSUMM-124510.736|37=LST_20260508_BYMA_CORI_NY1600095.1_1|55=[N/A]|60=20260508-16:45:10.781|1036=1|10=027

View File

@ -0,0 +1,341 @@
# Flow 8.4 — List Trading (Dealer Response)
## Overview
Flow 8.4 defines how a **dealer** responds to a client's Request for Quote (RFQ) through Tradeweb (TW) in a List Trading context. The key rule is:
> **The dealer NEVER sends an ExecutionReport (35=8). Only Tradeweb does.**
The dealer's role is limited to:
- Acknowledging messages (35=AI QuoteStatusReport, 35=BN ExecutionAck)
- Sending a price quote (35=S)
This document covers the **happy path** (client accepts) and two **alternative flows**:
- **Flow 8.6 — Trade Ended:** Client cancels before or after receiving the quote
- **QuoteAck Rejected:** TW rejects the dealer's quote
## Participants
| Abbreviation | Role |
|---|---|
| **TW** | Tradeweb — the platform that orchestrates the trade |
| **Dealer** | Us — responds to RFQs with quotes and acknowledges TW messages |
| **Client** | The counterparty requesting a quote (we never communicate with them directly) |
## Message Flow
```
TW Dealer
│ │
│ 1. QuoteRequest (35=R) │
│ ─────────────────────────────────────────> │
│ │
│ 2. QuoteStatusReport (35=AI) [ACK] │
│ <───────────────────────────────────────── │
│ │
│ 3. Quote (35=S) [price] │
│ <───────────────────────────────────────── │
│ │
│ 4. QuoteAck (35=CW) [ACCEPTED] │
│ ─────────────────────────────────────────> │
│ │
│ 5. QuoteResponse (35=AJ) [Hit/Lift] │
│ ─────────────────────────────────────────> │
│ │
│ 6. QuoteStatusReport (35=AI) [TRDREQACK] │
│ <───────────────────────────────────────── │
│ │
│ 7. ExecutionReport (35=8) [_LISTEND] │
│ ─────────────────────────────────────────> │
│ │
│ 8. ExecutionAck (35=BN) │
│ <───────────────────────────────────────── │
│ │
│ 9. ExecutionReport (35=8) [_TRDEND] │
│ ─────────────────────────────────────────> │
│ │
│ 10. ExecutionAck (35=BN) │
│ <───────────────────────────────────────── │
│ │
│ 11. ExecutionReport (35=8) [_TRDSUMM] │
│ ─────────────────────────────────────────> │
│ │
│ 12. ExecutionAck (35=BN) │
│ <───────────────────────────────────────── │
│ │
```
## Step-by-Step Detail
### Step 1 — QuoteRequest (35=R) — TW → Dealer
TW sends an RFQ on behalf of the client. Key fields:
| Tag | Field | Example | Notes |
|-----|-------|---------|-------|
| 131 | QuoteReqID | `LST_20260316_BYMA_CORI_NY1567246.1_1` | Always starts with `LST_` for List Trading |
| 66 | ListID | `NY1567246.1` | Identifies the list/inquiry |
| 48 | SecurityID | `040114HT0` | Bond identifier |
| 22 | SecurityIDSource | `1` (CUSIP) | Could also be `4` (ISIN) |
| 54 | Side | `2` (SELL) | The client's side — if client sells, dealer buys |
| 38 | OrderQty | `10000` | Quantity requested |
| 15 | Currency | `USD` | Settlement currency |
| 64 | SettlDate | `20260317` | Settlement date |
| 20073 | NegotiationType | `RFQ` | Must be RFQ for this flow |
**Validation:** The dealer must verify `LST_` prefix, non-empty `ListID`, and `NegotiationType=RFQ` before proceeding.
### Step 2 — QuoteStatusReport (35=AI) — Dealer → TW
The dealer acknowledges receipt of the QuoteRequest.
| Tag | Field | Value |
|-----|-------|-------|
| 131 | QuoteReqID | Same as received |
| 117 | QuoteID | Same as QuoteReqID |
| 297 | QuoteStatus | `0` (ACCEPTED) |
### Step 3 — Quote (35=S) — Dealer → TW
The dealer sends a price quote.
| Tag | Field | Example | Notes |
|-----|-------|---------|-------|
| 117 | QuoteID | Same as QuoteReqID | |
| 131 | QuoteReqID | Same as received | |
| 132 | BidPx | `99.60000000` | Set when client side is SELL (dealer bids) |
| 133 | OfferPx | `99.60000000` | Set when client side is BUY (dealer offers) |
| 44 | Price | `99.60000000` | The quote price |
| 423 | PriceType | `1` (Percentage) | |
| 537 | QuoteType | `211` (SEND_QUOTE) | |
### Step 4 — QuoteAck (35=CW) — TW → Dealer
TW confirms the quote was accepted.
| Tag | Field | Value |
|-----|-------|-------|
| 1865 | QuoteAckStatus | `1` (ACCEPTED) |
**Note:** If status is not ACCEPTED, the dealer logs the rejection (including the `Text` field) and cleans up the trade from memory. See [QuoteAck Rejected](#quoteack-rejected-quote-not-accepted) below.
### Step 5 — QuoteResponse (35=AJ) — TW → Dealer
The client has decided to trade (Hit/Lift the quote).
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 694 | QuoteRespType | `1` (Hit/Lift) | `2` would be Counter — that's flow 8.5, not handled here |
| 693 | QuoteRespID | `..._TRDREQ` | Always ends with `_TRDREQ` |
### Step 6 — QuoteStatusReport (35=AI) — Dealer → TW
The dealer acknowledges the trade request (TRDREQACK).
| Tag | Field | Value |
|-----|-------|-------|
| 131 | QuoteReqID | Same as received |
| 693 | QuoteRespID | Same as received |
| 297 | QuoteStatus | `0` (ACCEPTED) |
### Step 7 — ExecutionReport (35=8) `_LISTEND` — TW → Dealer
TW signals that the due-in window for the list has closed.
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 17 | ExecID | `..._LISTEND-{timestamp}` | Contains `_LISTEND` |
| 150 | ExecType | `A` (PendingNew) | |
| 39 | OrdStatus | `A` (PendingNew) | |
**Dealer action:** Send ExecutionAck only. **Do NOT send an ExecutionReport back.**
### Step 8 — ExecutionAck (35=BN) — Dealer → TW
| Tag | Field | Value |
|-----|-------|-------|
| 37 | OrderID | Same as received |
| 17 | ExecID | Same as received |
| 1036 | ExecAckStatus | `1` (ACCEPTED) |
### Step 9 — ExecutionReport (35=8) `_TRDEND` — TW → Dealer
TW sends the trade result.
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 17 | ExecID | `..._TRDEND-{timestamp}` | Contains `_TRDEND` |
| 150 | ExecType | `F` (Trade) | The trade was executed |
| 39 | OrdStatus | `2` (Filled) | |
| 44 | Price | `99.6` | Final execution price |
**Dealer action:** Send ExecutionAck. Clean up internal trade tracking state.
### Step 10 — ExecutionAck (35=BN) — Dealer → TW
Same format as Step 8.
### Step 11 — ExecutionReport (35=8) `_TRDSUMM` — TW → Dealer
TW sends the full trade summary with additional details (parties, settlement info, trade IDs).
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 17 | ExecID | `..._TRDSUMM-{timestamp}` | Contains `_TRDSUMM` |
| 150 | ExecType | `F` (Trade) | |
| 39 | OrdStatus | `2` (Filled) | |
| 453 | NoPartyIDs | Party information | Counterparty details |
| 526 | SecondaryClOrdID | `TRD_...` | Tradeweb trade reference |
| 1003 | TradeID | `20260316.BYMA.CORI.230` | Unique trade identifier |
**Dealer action:** Send ExecutionAck. Log the summary for audit/reconciliation.
### Step 12 — ExecutionAck (35=BN) — Dealer → TW
Same format as Step 8.
---
## Alternative Flows
### Flow 8.6 — Trade Ended (Client Cancels)
The client changes their mind and ends the trade. This can happen at any point after the QuoteRequest — even before the dealer's quote arrives. TW informs the dealer via QuoteResponse (35=AJ) messages with `_TRDEND` and `_TRDSUMM` suffixes instead of the ExecutionReport chain.
> **Critical:** The dealer MUST send a QuoteStatusReport (35=AI) ACK for every QuoteResponse. If no ACK is sent, TW will retry the message indefinitely (~every 11 seconds).
```
TW Dealer
│ │
│ 1. QuoteRequest (35=R) │
│ ─────────────────────────────────────────> │
│ │
│ 2. QuoteStatusReport (35=AI) [ACK] │
│ <───────────────────────────────────────── │
│ │
│ ┌─── Client ends trade ───┐ │
│ │ Meanwhile, dealer may │ │
│ │ still send Quote (S) │ │
│ └─────────────────────────┘ │
│ │
│ 3. QuoteResponse (35=AJ) [_TRDEND] │
│ QuoteRespType=7 (End Trade) │
│ ─────────────────────────────────────────> │
│ │
│ 4. QuoteStatusReport (35=AI) [ACK] │
│ <───────────────────────────────────────── │
│ │
│ 5. QuoteAck (35=CW) [REJECTED] │
│ (if quote was sent, TW rejects it) │
│ ─────────────────────────────────────────> │
│ │
│ 6. QuoteResponse (35=AJ) [_TRDSUMM] │
│ QuoteRespType=7, TradeSummary=Y │
│ ─────────────────────────────────────────> │
│ │
│ 7. QuoteStatusReport (35=AI) [ACK] │
│ <───────────────────────────────────────── │
│ │
```
#### Step 3 — QuoteResponse (35=AJ) `_TRDEND` — TW → Dealer
TW notifies that the client ended the trade.
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 693 | QuoteRespID | `..._TRDEND` | Suffix identifies this as trade end |
| 694 | QuoteRespType | `7` (End Trade) | |
| 131 | QuoteReqID | Same as original | |
**Dealer action:** Send QuoteStatusReport (35=AI) with `693=QuoteRespID` and `297=0` (ACCEPTED).
#### Step 5 — QuoteAck (35=CW) `REJECTED` — TW → Dealer
If the dealer's Quote (35=S) crossed with the TRDEND, TW rejects it. The QuoteAckStatus will be `2` (REJECTED) with a text like "DPL DLRQUOTE received in an invalid state."
**Dealer action:** Log the rejection and clean up the trade from memory.
#### Step 6 — QuoteResponse (35=AJ) `_TRDSUMM` — TW → Dealer
TW sends the final trade summary confirming the outcome.
| Tag | Field | Value | Notes |
|-----|-------|-------|-------|
| 693 | QuoteRespID | `..._TRDSUMM` | Final summary message |
| 694 | QuoteRespType | `7` (End Trade) | |
| 22636 | TradeSummary | `Y` | Confirms this is the summary |
**Dealer action:** Send QuoteStatusReport (35=AI) ACK. Clean up the trade from memory. This is the **terminal message** — no more messages will follow for this QuoteReqID.
---
### QuoteAck Rejected (Quote Not Accepted)
If TW rejects the dealer's Quote (35=CW with status != ACCEPTED), the trade is dead from the dealer's perspective.
```
TW Dealer
│ │
│ 1-3. (same as happy path) │
│ │
│ 4. QuoteAck (35=CW) [REJECTED] │
│ QuoteAckStatus != 1 │
│ ─────────────────────────────────────────> │
│ │
│ Trade is terminated. │
│ │
```
**Dealer action:** Log the rejection (including the `Text` field with the reason) and remove the trade from memory. No further action needed — TW may or may not send subsequent messages for this QuoteReqID.
---
## QuoteRespID Suffix Routing in `handleQuoteResponse`
All QuoteResponse (35=AJ) messages are routed by the suffix of the `QuoteRespID` (tag 693):
```
QuoteRespID ends with "_TRDREQ" → Trade request (flow 8.4 happy path) — ACK
QuoteRespID ends with "_TRDEND" → Trade ended by client (flow 8.6) — ACK
QuoteRespID ends with "_TRDSUMM" → Trade summary (flow 8.6 final) — ACK + cleanup
QuoteRespID ends with "_LISTEND" → List ended — ACK
Other suffix → Ignored (logged)
```
## Code Reference
The implementation lives in `src/client/fix/manager.go`:
| Handler | Triggers on | Action |
|---------|------------|--------|
| `handleQuoteRequest` | 35=R | Sends 35=AI (ack) + 35=S (quote) |
| `handleQuoteAck` | 35=CW | If rejected: logs + cleans up trade. If accepted: logs |
| `handleQuoteResponse` | 35=AJ | Sends 35=AI (ACK). Routes by QuoteRespID suffix. Cleans up on `_TRDSUMM` |
| `handleExecutionReport` | 35=8 | Sends 35=BN (ack) + routes by ExecID suffix |
| `sendQuoteStatusReport` | — | Builds 35=AI for QuoteRequest ack |
| `sendTradeRequestAck` | — | Builds 35=AI for QuoteResponse ack (all suffixes) |
| `sendExecutionAck` | — | Builds 35=BN for ExecutionReport ack |
### ExecID Routing in `handleExecutionReport`
```
ExecID contains "_LISTEND" → Log only, await trade result
ExecID contains "_TRDEND" → Log trade end
ExecID contains "_TRDSUMM" → Log trade summary + cleanup trade from memory
ExecType = F (fallback) → Log generic trade result
```
The order matters: ExecID suffix checks run before ExecType checks, because `_TRDEND` and `_TRDSUMM` both have `ExecType=F`.
### Trade Cleanup Paths
A trade is removed from memory in any of these scenarios:
| Trigger | Message | Condition |
|---------|---------|-----------|
| QuoteAck rejected | 35=CW | `QuoteAckStatus != ACCEPTED` |
| QuoteResponse summary | 35=AJ | `QuoteRespID` ends with `_TRDSUMM` (flow 8.6) |
| ExecutionReport summary | 35=8 | `ExecID` contains `_TRDSUMM` (flow 8.4) |
The `loadActiveTrades` recovery function replays today's messages and applies the same cleanup rules to reconstruct accurate state on restart.

10
docs/Timeout.log Normal file
View File

@ -0,0 +1,10 @@
8=FIXT.1.1|9=858|35=R|34=416|49=TRADEWEB|52=20260508-16:52:50.205|56=BYMA_CORI_TEST_12345_DLRDPL|131=LST_20260508_BYMA_CORI_NY1600105.2_1|146=1|55=ARGENT 1.500 07/09/35|48=040114HT0|22=1|460=12|167=CORP|762=REGCORIINV|541=20350709|225=20200904|470=AR|223=1.5|106=ARGENTINE REPUBLIC|54=2|38=15000|64=20260511|15=USD|6110=Sov|60=20260508-16:52:50|423=1|44=-999999|236=-999999|5023=0.000010|66=NY1600105.2|6847=1|75=20260508|464=Y|20086=1|20074=Y|20075=Y|20077=[N/A]|20078=[N/A]|20079=60|20081=60|20090=0|20072=60|20098=0|5745=1|20073=RFQ|20076=Y|20156=Y|20130=20501720000|20138=HSB|561=1|562=1|20175=20210709|20223=American|22630=0|20265=LatAm|20227=Global|23068=TRWB|453=3|448=bymacust|447=C|452=3|802=3|523=BYMA CUST|803=2|523=NY|803=25|523=AR|803=4000|448=BYMA Customer|447=C|452=1|448=DTCC|447=C|452=4|454=1|455=US040114HT09|456=4|5114=2|5113=1|20169=Ca|5113=0|20169=CCC+|10=195
8=FIXT.1.1|9=203|35=AI|34=421|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:52:50.226|56=TRADEWEB|55=[N/A]|60=20260508-16:52:50.226|117=LST_20260508_BYMA_CORI_NY1600105.2_1|131=LST_20260508_BYMA_CORI_NY1600105.2_1|297=0|10=131
8=FIXT.1.1|9=80|35=0|34=422|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:53:20.289|56=TRADEWEB|10=181
8=FIXT.1.1|9=80|35=0|34=417|49=TRADEWEB|52=20260508-16:53:20.291|56=BYMA_CORI_TEST_12345_DLRDPL|10=178
8=FIXT.1.1|9=80|35=0|34=423|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:53:50.295|56=TRADEWEB|10=182
8=FIXT.1.1|9=80|35=0|34=418|49=TRADEWEB|52=20260508-16:53:50.305|56=BYMA_CORI_TEST_12345_DLRDPL|10=178
8=FIXT.1.1|9=265|35=AJ|34=419|49=TRADEWEB|52=20260508-16:53:50.698|56=BYMA_CORI_TEST_12345_DLRDPL|11=LST_20260508_BYMA_CORI_NY1600105.2_1|55=[N/A]|60=20260508-16:53:50|117=[N/A]|131=LST_20260508_BYMA_CORI_NY1600105.2_1|693=LST_20260508_BYMA_CORI_NY1600105.2_1_LISTEND|694=8|20086=1|10=010
8=FIXT.1.1|9=211|35=AI|34=424|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:53:50.721|56=TRADEWEB|55=[N/A]|60=20260508-16:53:50.721|131=LST_20260508_BYMA_CORI_NY1600105.2_1|297=0|693=LST_20260508_BYMA_CORI_NY1600105.2_1_LISTEND|10=002
8=FIXT.1.1|9=292|35=AJ|34=420|49=TRADEWEB|52=20260508-16:53:53.741|56=BYMA_CORI_TEST_12345_DLRDPL|11=LST_20260508_BYMA_CORI_NY1600105.2_1|55=[N/A]|60=20260508-16:53:53|117=[N/A]|131=LST_20260508_BYMA_CORI_NY1600105.2_1|693=LST_20260508_BYMA_CORI_NY1600105.2_1_LISTEND|694=8|20086=1|20103=N/A|20110=NO|22636=Y|10=088
8=FIXT.1.1|9=211|35=AI|34=425|49=BYMA_CORI_TEST_12345_DLRDPL|52=20260508-16:53:53.763|56=TRADEWEB|55=[N/A]|60=20260508-16:53:53.763|131=LST_20260508_BYMA_CORI_NY1600105.2_1|297=0|693=LST_20260508_BYMA_CORI_NY1600105.2_1_LISTEND|10=021

19
fix.cfg Normal file
View File

@ -0,0 +1,19 @@
[DEFAULT]
ConnectionType=acceptor
HeartBtInt=30
SenderCompID=QUANTEX
ResetOnLogon=Y
FileStorePath=fix_store
FileLogPath=fix_logs
TransportDataDictionary=spec/FIXT11.xml
AppDataDictionary=spec/FIX50SP2.xml
AllowUnknownMessageFields=Y
RejectInvalidMessage=N
[SESSION]
BeginString=FIXT.1.1
DefaultApplVerID=FIX.5.0SP2
TargetCompID=CLIENT
StartTime=00:00:00
EndTime=00:00:00
SocketAcceptPort=5001

View File

@ -178,6 +178,10 @@ func parseLogLevel(level string) (slog.Level, error) {
func startRunner(runner, globalCfg, serviceCfg string) { func startRunner(runner, globalCfg, serviceCfg string) {
var fn func(cfg app.Config) error var fn func(cfg app.Config) error
if runner == "" {
runner = "service"
}
switch runner { switch runner {
case "service": case "service":
fn = service.Runner fn = service.Runner

View File

@ -89,6 +89,24 @@ func (m FieldMap) Get(parser Field) MessageRejectError {
return m.GetField(parser.Tag(), parser) return m.GetField(parser.Tag(), parser)
} }
// RawValues returns a copy of the underlying TagValue slice for a tag.
// For repeating groups, the first entry is the count and the remaining
// entries are the flattened inner-field values across all repetitions.
func (m FieldMap) RawValues(tag Tag) []TagValue {
m.rwLock.RLock()
defer m.rwLock.RUnlock()
f, ok := m.tagLookup[tag]
if !ok {
return nil
}
out := make([]TagValue, len(f))
copy(out, f)
return out
}
// Has returns true if the Tag is present in this FieldMap. // Has returns true if the Tag is present in this FieldMap.
func (m FieldMap) Has(tag Tag) bool { func (m FieldMap) Has(tag Tag) bool {
m.rwLock.RLock() m.rwLock.RLock()

View File

@ -86,6 +86,16 @@ func (tv TagValue) String() string {
return string(tv.bytes) return string(tv.bytes)
} }
// Tag returns the FIX tag for this TagValue.
func (tv TagValue) Tag() Tag {
return tv.tag
}
// Value returns the raw FIX value bytes for this TagValue.
func (tv TagValue) Value() []byte {
return tv.value
}
func bytesTotal(bytes []byte) (total int) { func bytesTotal(bytes []byte) (total int) {
for _, b := range bytes { for _, b := range bytes {
total += int(b) total += int(b)

313
spec/FIXT11.xml Normal file
View File

@ -0,0 +1,313 @@
<?xml version='1.0' encoding='UTF-8'?>
<fix type='FIXT' major='1' minor='1' servicepack='0'>
<header>
<field name='BeginString' required='Y'/>
<field name='BodyLength' required='Y'/>
<field name='MsgType' required='Y'/>
<field name='SenderCompID' required='Y'/>
<field name='TargetCompID' required='Y'/>
<field name='OnBehalfOfCompID' required='N'/>
<field name='DeliverToCompID' required='N'/>
<field name='SecureDataLen' required='N'/>
<field name='SecureData' required='N'/>
<field name='MsgSeqNum' required='Y'/>
<field name='SenderSubID' required='N'/>
<field name='SenderLocationID' required='N'/>
<field name='TargetSubID' required='N'/>
<field name='TargetLocationID' required='N'/>
<field name='OnBehalfOfSubID' required='N'/>
<field name='OnBehalfOfLocationID' required='N'/>
<field name='DeliverToSubID' required='N'/>
<field name='DeliverToLocationID' required='N'/>
<field name='PossDupFlag' required='N'/>
<field name='PossResend' required='N'/>
<field name='SendingTime' required='Y'/>
<field name='OrigSendingTime' required='N'/>
<field name='XmlDataLen' required='N'/>
<field name='XmlData' required='N'/>
<field name='MessageEncoding' required='N'/>
<field name='LastMsgSeqNumProcessed' required='N'/>
<component name='HopGrp' required='N'/>
<field name='ApplVerID' required='N'/>
<field name='CstmApplVerID' required='N'/>
</header>
<trailer>
<field name='SignatureLength' required='N'/>
<field name='Signature' required='N'/>
<field name='CheckSum' required='Y'/>
</trailer>
<messages>
<message msgcat='admin' msgtype='0' name='Heartbeat'>
<field name='TestReqID' required='N'/>
</message>
<message msgcat='admin' msgtype='1' name='TestRequest'>
<field name='TestReqID' required='Y'/>
</message>
<message msgcat='admin' msgtype='2' name='ResendRequest'>
<field name='BeginSeqNo' required='Y'/>
<field name='EndSeqNo' required='Y'/>
</message>
<message msgcat='admin' msgtype='3' name='Reject'>
<field name='RefSeqNum' required='Y'/>
<field name='RefTagID' required='N'/>
<field name='RefMsgType' required='N'/>
<field name='SessionRejectReason' required='N'/>
<field name='Text' required='N'/>
<field name='EncodedTextLen' required='N'/>
<field name='EncodedText' required='N'/>
</message>
<message msgcat='admin' msgtype='4' name='SequenceReset'>
<field name='GapFillFlag' required='N'/>
<field name='NewSeqNo' required='Y'/>
</message>
<message msgcat='admin' msgtype='5' name='Logout'>
<field name='Text' required='N'/>
<field name='EncodedTextLen' required='N'/>
<field name='EncodedText' required='N'/>
</message>
<message msgcat='admin' msgtype='A' name='Logon'>
<field name='EncryptMethod' required='Y'/>
<field name='HeartBtInt' required='Y'/>
<field name='RawDataLength' required='N'/>
<field name='RawData' required='N'/>
<field name='ResetSeqNumFlag' required='N'/>
<field name='NextExpectedMsgSeqNum' required='N'/>
<field name='MaxMessageSize' required='N'/>
<field name='TestMessageIndicator' required='N'/>
<field name='Username' required='N'/>
<field name='Password' required='N'/>
<field name='DefaultApplVerID' required='Y'/>
<component name='MsgTypeGrp' required='N'/>
</message>
</messages>
<components>
<component name='HopGrp'>
<group name='NoHops' required='N'>
<field name='HopCompID' required='N'/>
<field name='HopSendingTime' required='N'/>
<field name='HopRefID' required='N'/>
</group>
</component>
<component name='MsgTypeGrp'>
<group name='NoMsgTypes' required='N'>
<field name='RefMsgType' required='N'/>
<field name='MsgDirection' required='N'/>
<field name='RefApplVerID' required='N'/>
<field name='RefCstmApplVerID' required='N'/>
</group>
</component>
</components>
<fields>
<field name='BeginSeqNo' number='7' type='SEQNUM'/>
<field name='BeginString' number='8' type='STRING'/>
<field name='BodyLength' number='9' type='LENGTH'/>
<field name='CheckSum' number='10' type='STRING'/>
<field name='EndSeqNo' number='16' type='SEQNUM'/>
<field name='MsgSeqNum' number='34' type='SEQNUM'/>
<field number='35' name='MsgType' type='STRING'>
<value enum='0' description='HEARTBEAT'/>
<value enum='1' description='TEST_REQUEST'/>
<value enum='2' description='RESEND_REQUEST'/>
<value enum='3' description='REJECT'/>
<value enum='4' description='SEQUENCE_RESET'/>
<value enum='5' description='LOGOUT'/>
<value enum='6' description='INDICATION_OF_INTEREST'/>
<value enum='7' description='ADVERTISEMENT'/>
<value enum='8' description='EXECUTION_REPORT'/>
<value enum='9' description='ORDER_CANCEL_REJECT'/>
<value enum='A' description='LOGON'/>
<value enum='B' description='NEWS'/>
<value enum='C' description='EMAIL'/>
<value enum='D' description='ORDER_SINGLE'/>
<value enum='E' description='ORDER_LIST'/>
<value enum='F' description='ORDER_CANCEL_REQUEST'/>
<value enum='G' description='ORDER_CANCEL_REPLACE_REQUEST'/>
<value enum='H' description='ORDER_STATUS_REQUEST'/>
<value enum='J' description='ALLOCATION_INSTRUCTION'/>
<value enum='K' description='LIST_CANCEL_REQUEST'/>
<value enum='L' description='LIST_EXECUTE'/>
<value enum='M' description='LIST_STATUS_REQUEST'/>
<value enum='N' description='LIST_STATUS'/>
<value enum='P' description='ALLOCATION_INSTRUCTION_ACK'/>
<value enum='Q' description='DONT_KNOW_TRADE'/>
<value enum='R' description='QUOTE_REQUEST'/>
<value enum='S' description='QUOTE'/>
<value enum='T' description='SETTLEMENT_INSTRUCTIONS'/>
<value enum='V' description='MARKET_DATA_REQUEST'/>
<value enum='W' description='MARKET_DATA_SNAPSHOT_FULL_REFRESH'/>
<value enum='X' description='MARKET_DATA_INCREMENTAL_REFRESH'/>
<value enum='Y' description='MARKET_DATA_REQUEST_REJECT'/>
<value enum='Z' description='QUOTE_CANCEL'/>
<value enum='a' description='QUOTE_STATUS_REQUEST'/>
<value enum='b' description='MASS_QUOTE_ACKNOWLEDGEMENT'/>
<value enum='c' description='SECURITY_DEFINITION_REQUEST'/>
<value enum='d' description='SECURITY_DEFINITION'/>
<value enum='e' description='SECURITY_STATUS_REQUEST'/>
<value enum='f' description='SECURITY_STATUS'/>
<value enum='g' description='TRADING_SESSION_STATUS_REQUEST'/>
<value enum='h' description='TRADING_SESSION_STATUS'/>
<value enum='i' description='MASS_QUOTE'/>
<value enum='j' description='BUSINESS_MESSAGE_REJECT'/>
<value enum='k' description='BID_REQUEST'/>
<value enum='l' description='BID_RESPONSE'/>
<value enum='m' description='LIST_STRIKE_PRICE'/>
<value enum='n' description='XML_MESSAGE'/>
<value enum='o' description='REGISTRATION_INSTRUCTIONS'/>
<value enum='p' description='REGISTRATION_INSTRUCTIONS_RESPONSE'/>
<value enum='q' description='ORDER_MASS_CANCEL_REQUEST'/>
<value enum='r' description='ORDER_MASS_CANCEL_REPORT'/>
<value enum='s' description='NEW_ORDER_CROSS'/>
<value enum='t' description='CROSS_ORDER_CANCEL_REPLACE_REQUEST'/>
<value enum='u' description='CROSS_ORDER_CANCEL_REQUEST'/>
<value enum='v' description='SECURITY_TYPE_REQUEST'/>
<value enum='w' description='SECURITY_TYPES'/>
<value enum='x' description='SECURITY_LIST_REQUEST'/>
<value enum='y' description='SECURITY_LIST'/>
<value enum='z' description='DERIVATIVE_SECURITY_LIST_REQUEST'/>
<value enum='AA' description='DERIVATIVE_SECURITY_LIST'/>
<value enum='AB' description='NEW_ORDER_MULTILEG'/>
<value enum='AC' description='MULTILEG_ORDER_CANCEL_REPLACE'/>
<value enum='AD' description='TRADE_CAPTURE_REPORT_REQUEST'/>
<value enum='AE' description='TRADE_CAPTURE_REPORT'/>
<value enum='AF' description='ORDER_MASS_STATUS_REQUEST'/>
<value enum='AG' description='QUOTE_REQUEST_REJECT'/>
<value enum='AH' description='RFQ_REQUEST'/>
<value enum='AI' description='QUOTE_STATUS_REPORT'/>
<value enum='AJ' description='QUOTE_RESPONSE'/>
<value enum='AK' description='CONFIRMATION'/>
<value enum='AL' description='POSITION_MAINTENANCE_REQUEST'/>
<value enum='AM' description='POSITION_MAINTENANCE_REPORT'/>
<value enum='AN' description='REQUEST_FOR_POSITIONS'/>
<value enum='AO' description='REQUEST_FOR_POSITIONS_ACK'/>
<value enum='AP' description='POSITION_REPORT'/>
<value enum='AQ' description='TRADE_CAPTURE_REPORT_REQUEST_ACK'/>
<value enum='AR' description='TRADE_CAPTURE_REPORT_ACK'/>
<value enum='AS' description='ALLOCATION_REPORT'/>
<value enum='AT' description='ALLOCATION_REPORT_ACK'/>
<value enum='AU' description='CONFIRMATION_ACK'/>
<value enum='AV' description='SETTLEMENT_INSTRUCTION_REQUEST'/>
<value enum='AW' description='ASSIGNMENT_REPORT'/>
<value enum='AX' description='COLLATERAL_REQUEST'/>
<value enum='AY' description='COLLATERAL_ASSIGNMENT'/>
<value enum='AZ' description='COLLATERAL_RESPONSE'/>
<value enum='BA' description='COLLATERAL_REPORT'/>
<value enum='BB' description='COLLATERAL_INQUIRY'/>
<value enum='BC' description='NETWORK_STATUS_REQUEST'/>
<value enum='BD' description='NETWORK_STATUS_RESPONSE'/>
<value enum='BE' description='USER_REQUEST'/>
<value enum='BF' description='USER_RESPONSE'/>
<value enum='BG' description='COLLATERAL_INQUIRY_ACK'/>
<value enum='BH' description='CONFIRMATION_REQUEST'/>
<value enum='BI' description='TRADING_SESSION_LIST_REQUEST'/>
<value enum='BJ' description='TRADING_SESSION_LIST'/>
<value enum='BK' description='SECURITY_LIST_UPDATE_REPORT'/>
<value enum='BL' description='ADJUSTED_POSITION_REPORT'/>
<value enum='BM' description='ALLOCATION_INSTRUCTION_ALERT'/>
<value enum='BN' description='EXECUTION_ACKNOWLEDGEMENT'/>
<value enum='BO' description='CONTRARY_INTENTION_REPORT'/>
<value enum='BP' description='SECURITY_DEFINITION_UPDATE_REPORT'/>
</field>
<field name='NewSeqNo' number='36' type='SEQNUM'/>
<field name='PossDupFlag' number='43' type='BOOLEAN'/>
<field name='RefSeqNum' number='45' type='SEQNUM'/>
<field name='SenderCompID' number='49' type='STRING'/>
<field name='SenderSubID' number='50' type='STRING'/>
<field name='SendingTime' number='52' type='UTCTIMESTAMP'/>
<field name='TargetCompID' number='56' type='STRING'/>
<field name='TargetSubID' number='57' type='STRING'/>
<field name='Text' number='58' type='STRING'/>
<field name='Signature' number='89' type='DATA'/>
<field name='SecureDataLen' number='90' type='LENGTH'/>
<field name='SecureData' number='91' type='DATA'/>
<field name='SignatureLength' number='93' type='LENGTH'/>
<field name='RawDataLength' number='95' type='LENGTH'/>
<field name='RawData' number='96' type='DATA'/>
<field name='PossResend' number='97' type='BOOLEAN'/>
<field name='EncryptMethod' number='98' type='INT'>
<value description='NONE_OTHER' enum='0'/>
<value description='PKCS' enum='1'/>
<value description='DES' enum='2'/>
<value description='PKCS_DES' enum='3'/>
<value description='PGP_DES' enum='4'/>
<value description='PGP_DES_MD5' enum='5'/>
<value description='PEM_DES_MD5' enum='6'/>
</field>
<field name='HeartBtInt' number='108' type='INT'/>
<field name='TestReqID' number='112' type='STRING'/>
<field name='OnBehalfOfCompID' number='115' type='STRING'/>
<field name='OnBehalfOfSubID' number='116' type='STRING'/>
<field name='OrigSendingTime' number='122' type='UTCTIMESTAMP'/>
<field name='GapFillFlag' number='123' type='BOOLEAN'/>
<field name='DeliverToCompID' number='128' type='STRING'/>
<field name='DeliverToSubID' number='129' type='STRING'/>
<field name='ResetSeqNumFlag' number='141' type='BOOLEAN'/>
<field name='SenderLocationID' number='142' type='STRING'/>
<field name='TargetLocationID' number='143' type='STRING'/>
<field name='OnBehalfOfLocationID' number='144' type='STRING'/>
<field name='DeliverToLocationID' number='145' type='STRING'/>
<field name='XmlDataLen' number='212' type='LENGTH'/>
<field name='XmlData' number='213' type='DATA'/>
<field number='347' name='MessageEncoding' type='STRING'>
<value enum='ISO-2022-JP' description='ISO_2022_JP'/>
<value enum='EUC-JP' description='EUC_JP'/>
<value enum='SHIFT_JIS' description='SHIFT_JIS'/>
<value enum='UTF-8' description='UTF_8'/>
</field>
<field name='EncodedTextLen' number='354' type='LENGTH'/>
<field name='EncodedText' number='355' type='DATA'/>
<field name='LastMsgSeqNumProcessed' number='369' type='SEQNUM'/>
<field name='RefTagID' number='371' type='INT'/>
<field name='RefMsgType' number='372' type='STRING'/>
<field name='SessionRejectReason' number='373' type='INT'>
<value description='INVALID_TAG_NUMBER' enum='0'/>
<value description='REQUIRED_TAG_MISSING' enum='1'/>
<value description='SENDINGTIME_ACCURACY_PROBLEM' enum='10'/>
<value description='INVALID_MSGTYPE' enum='11'/>
<value description='XML_VALIDATION_ERROR' enum='12'/>
<value description='TAG_APPEARS_MORE_THAN_ONCE' enum='13'/>
<value description='TAG_SPECIFIED_OUT_OF_REQUIRED_ORDER' enum='14'/>
<value description='REPEATING_GROUP_FIELDS_OUT_OF_ORDER' enum='15'/>
<value description='INCORRECT_NUMINGROUP_COUNT_FOR_REPEATING_GROUP' enum='16'/>
<value description='NON_DATA_VALUE_INCLUDES_FIELD_DELIMITER' enum='17'/>
<value description='TAG_NOT_DEFINED_FOR_THIS_MESSAGE_TYPE' enum='2'/>
<value description='UNDEFINED_TAG' enum='3'/>
<value description='TAG_SPECIFIED_WITHOUT_A_VALUE' enum='4'/>
<value description='VALUE_IS_INCORRECT' enum='5'/>
<value description='INCORRECT_DATA_FORMAT_FOR_VALUE' enum='6'/>
<value description='DECRYPTION_PROBLEM' enum='7'/>
<value description='SIGNATURE_PROBLEM' enum='8'/>
<value description='COMPID_PROBLEM' enum='9'/>
<value description='OTHER' enum='99'/>
</field>
<field name='MaxMessageSize' number='383' type='LENGTH'/>
<field name='NoMsgTypes' number='384' type='NUMINGROUP'/>
<field name='MsgDirection' number='385' type='CHAR'>
<value description='RECEIVE' enum='R'/>
<value description='SEND' enum='S'/>
</field>
<field name='TestMessageIndicator' number='464' type='BOOLEAN'/>
<field name='Username' number='553' type='STRING'/>
<field name='Password' number='554' type='STRING'/>
<field name='NoHops' number='627' type='NUMINGROUP'/>
<field name='HopCompID' number='628' type='STRING'/>
<field name='HopSendingTime' number='629' type='UTCTIMESTAMP'/>
<field name='HopRefID' number='630' type='SEQNUM'/>
<field name='NextExpectedMsgSeqNum' number='789' type='SEQNUM'/>
<field name='ApplVerID' number='1128' type='STRING'>
<value description='FIX27' enum='0'/>
<value description='FIX30' enum='1'/>
<value description='FIX40' enum='2'/>
<value description='FIX41' enum='3'/>
<value description='FIX42' enum='4'/>
<value description='FIX43' enum='5'/>
<value description='FIX44' enum='6'/>
<value description='FIX50' enum='7'/>
<value description='FIX50SP1' enum='8'/>
<value description='FIX50SP2' enum='9'/>
</field>
<field name='CstmApplVerID' number='1129' type='STRING'/>
<field name='RefApplVerID' number='1130' type='STRING'/>
<field name='RefCstmApplVerID' number='1131' type='STRING'/>
<field name='DefaultApplVerID' number='1137' type='STRING'/>
</fields>
</fix>

View File

@ -36,6 +36,16 @@ type Service struct {
AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"` AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"`
APIBasePort string APIBasePort string
EnableJWTAuth bool // Enable JWT authentication for service-to-service communication EnableJWTAuth bool // Enable JWT authentication for service-to-service communication
// ServiceAPIKey is the shared secret that authenticates qbymarouter (and any
// other internal service) when posting to /qfixdpl/v1/quotes and
// /qfixdpl/v1/messages. Must match the DPLAPIKey configured on the caller.
ServiceAPIKey string
FIX FIXConfig
}
type FIXConfig struct {
SettingsFile string // path to fix.cfg file
DataDictionaryFile string // path to FIX data dictionary XML (e.g. spec/FIX50SP2.xml)
} }
type ExtAuth struct { type ExtAuth struct {

View File

@ -3,7 +3,6 @@ package version
import ( import (
"fmt" "fmt"
"os"
"runtime" "runtime"
"strings" "strings"
) )
@ -38,17 +37,17 @@ type EnvironmentType int //nolint:recvcheck // The methods of this are autogener
var environment EnvironmentType //nolint:gochecknoglobals // Just keept this global to avoid having to create an instance var environment EnvironmentType //nolint:gochecknoglobals // Just keept this global to avoid having to create an instance
func init() { func init() {
aux := os.Getenv(quantexEnvironment) // aux := os.Getenv(EnvironmentTypeDev)
if aux == "" { // if aux == "" {
panic("QUANTEX_ENVIRONMENT is not set") // panic("QUANTEX_ENVIRONMENT is not set")
} // }
env, err := ParseEnvironmentType(aux) // env, err := ParseEnvironmentType(aux)
if err != nil { // if err != nil {
panic("Invalid QUANTEX_ENVIRONMENT value: " + aux + " " + err.Error()) // panic("Invalid QUANTEX_ENVIRONMENT value: " + aux + " " + err.Error())
} // }
environment = env environment = EnvironmentTypeDev
} }
// Base returns the version base name // Base returns the version base name

View File

@ -4,12 +4,14 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gomodule/redigo/redis" "github.com/gomodule/redigo/redis"
"github.com/sasha-s/go-deadlock" "github.com/sasha-s/go-deadlock"
uuid "github.com/satori/go.uuid" uuid "github.com/satori/go.uuid"
"github.com/shopspring/decimal"
"quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/app/version" "quantex.com/qfixdpl/src/app/version"
@ -29,23 +31,25 @@ const (
) )
type Controller struct { type Controller struct {
pool *redis.Pool pool *redis.Pool
userData app.UserDataProvider userData app.UserDataProvider
store *store.Store store *store.Store
config Config tradeProvider TradeProvider
notify domain.Notifier config Config
authMutex deadlock.Mutex notify domain.Notifier
authMutex deadlock.Mutex
} }
func newController(pool *redis.Pool, userData app.UserDataProvider, func newController(pool *redis.Pool, userData app.UserDataProvider,
s *store.Store, config Config, n domain.Notifier, s *store.Store, tradeProvider TradeProvider, config Config, n domain.Notifier,
) *Controller { ) *Controller {
return &Controller{ return &Controller{
pool: pool, pool: pool,
userData: userData, userData: userData,
store: s, store: s,
config: config, tradeProvider: tradeProvider,
notify: n, config: config,
notify: n,
} }
} }
@ -288,3 +292,178 @@ func allowed(origin string, config Config) bool {
return false return false
} }
// GetTrades godoc
// @Summary List active trades
// @Description Returns all active List Trading trades
// @Tags fix
// @Produce json
// @Success 200 {array} domain.ListTrade
// @Router /qfixdpl/v1/trades [get]
func (cont *Controller) GetTrades(ctx *gin.Context) {
trades := cont.tradeProvider.GetTrades()
ctx.JSON(http.StatusOK, trades)
}
// GetLogs godoc
// @Summary Get raw FIX logs for a trade
// @Description Returns raw FIX message logs for a given QuoteReqID
// @Tags fix
// @Produce json
// @Param quoteReqID path string true "QuoteReqID"
// @Success 200 {object} domain.Logs
// @Router /qfixdpl/v1/trades/{quoteReqID}/logs [get]
func (cont *Controller) GetLogs(ctx *gin.Context) {
quoteReqID := ctx.Param("quoteReqID")
logs, err := cont.store.GetLogsByQuoteReqID(quoteReqID)
if err != nil {
err = tracerr.Errorf("GetLogs: error fetching logs (quoteReqID=%s): %w", quoteReqID, err)
slog.Error(err.Error())
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "error fetching logs"})
return
}
ctx.JSON(http.StatusOK, logs)
}
// AllMessages godoc
// @Summary List FIX application messages of the day after the caller's last-seen sequence
// @Description Returns today's FIX application messages (no admin: heartbeats/logon/logout/etc.) with MsgSeqNum greater than the caller's last-seen sequence per direction. "In" is the last MsgSeqNum the caller received on the IN side; "Out" is the same for OUT. Pass 0 to receive everything on that side. Sorted by CreatedAt ascending.
// @Tags fix
// @Accept json
// @Produce json
// @Param body body AllMessagesRequest true "API key and last-seen MsgSeqNum per direction"
// @Success 200 {array} domain.Message
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Router /qfixdpl/v1/messages [post]
func (cont *Controller) AllMessages(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req AllMessagesRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
ctx.JSON(http.StatusOK, cont.tradeProvider.GetAllMessages(req.In, req.Out))
}
// checkServiceAPIKey returns true when the provided key matches the configured
// service-to-service shared secret. Empty configured key is always rejected
// to avoid open authentication when misconfigured.
func (cont *Controller) checkServiceAPIKey(key string) bool {
return cont.config.ServiceAPIKey != "" && key == cont.config.ServiceAPIKey
}
// GetPendingQuoteRequests godoc
// @Summary List pending QuoteRequests
// @Description Returns all QuoteRequests received from TW that have not been quoted yet by the dealer
// @Tags fix
// @Produce json
// @Success 200 {array} domain.ListTrade
// @Router /qfixdpl/v1/quote-requests [get]
func (cont *Controller) GetPendingQuoteRequests(ctx *gin.Context) {
pending := cont.tradeProvider.GetPendingQuoteRequests()
ctx.JSON(http.StatusOK, pending)
}
// SendQuote godoc
// @Summary Send a Quote for a pending QuoteRequest
// @Description Builds and sends a Quote (35=S) to TW for an existing QuoteRequest at the given price
// @Tags fix
// @Accept json
// @Produce json
// @Param body body SendQuoteRequest true "Quote to send"
// @Success 200 {object} Msg
// @Failure 400 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 409 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /qfixdpl/v1/quotes [post]
func (cont *Controller) SendQuote(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req SendQuoteRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
price, err := decimal.NewFromString(req.Price)
if err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: "invalid price: " + err.Error()})
return
}
if err := cont.tradeProvider.SendQuote(req.QuoteReqID, price); err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"})
case strings.Contains(msg, "already sent"):
ctx.JSON(http.StatusConflict, HTTPError{Error: "quote already sent for this quoteReqID"})
default:
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to send quote"})
}
return
}
ctx.JSON(http.StatusOK, Msg{Text: "Quote sent"})
}
// CancelQuote godoc
// @Summary Cancel a Quote for a QuoteRequest
// @Description Builds and sends a QuoteCancel (35=Z) to TW for an existing QuoteRequest
// @Tags fix
// @Accept json
// @Produce json
// @Param body body CancelQuoteRequest true "Quote cancel request"
// @Success 200 {object} Msg
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 409 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /qfixdpl/v1/quotes/cancel [post]
func (cont *Controller) CancelQuote(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req CancelQuoteRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
if err := cont.tradeProvider.CancelQuote(req.QuoteReqID, req.Text); err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"})
case strings.Contains(msg, "cannot respond"):
ctx.JSON(http.StatusConflict, HTTPError{Error: "quote request cannot be cancelled"})
default:
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to cancel quote"})
}
return
}
ctx.JSON(http.StatusOK, Msg{Text: "Quote cancel sent"})
}

View File

@ -16,3 +16,21 @@ type Credentials struct {
type Session struct { type Session struct {
Email string Email string
} }
type SendQuoteRequest struct {
APIKey string `json:"APIKey" binding:"required"`
QuoteReqID string `json:"QuoteReqID" binding:"required"`
Price string `json:"Price" binding:"required" example:"99.6"`
}
type CancelQuoteRequest struct {
APIKey string `json:"APIKey" binding:"required"`
QuoteReqID string `json:"QuoteReqID" binding:"required"`
Text string `json:"Text,omitempty"`
}
type AllMessagesRequest struct {
APIKey string
In int
Out int
}

View File

@ -21,6 +21,15 @@ func SetRoutes(api *API) {
qfixdpl := v1.Group("/") qfixdpl := v1.Group("/")
qfixdpl.Use(cont.AuthRequired) qfixdpl.Use(cont.AuthRequired)
qfixdpl.GET("/health", cont.HealthCheck) qfixdpl.GET("/health", cont.HealthCheck)
qfixdpl.GET("/trades", cont.GetTrades)
qfixdpl.GET("/trades/:quoteReqID/logs", cont.GetLogs)
qfixdpl.GET("/quote-requests", cont.GetPendingQuoteRequests)
// services group: API-key auth via body, no session cookie required.
services := v1.Group("/")
services.POST("/messages", cont.AllMessages)
services.POST("/quotes", cont.SendQuote)
services.POST("/quotes/cancel", cont.CancelQuote)
backoffice := qfixdpl.Group("/backoffice") backoffice := qfixdpl.Group("/backoffice")
backoffice.Use(cont.BackOfficeUser) backoffice.Use(cont.BackOfficeUser)

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gomodule/redigo/redis" "github.com/gomodule/redigo/redis"
"github.com/shopspring/decimal"
"quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/app/version" "quantex.com/qfixdpl/src/app/version"
@ -16,6 +17,15 @@ import (
"quantex.com/qfixdpl/src/domain" "quantex.com/qfixdpl/src/domain"
) )
// TradeProvider exposes trade data from the FIX manager.
type TradeProvider interface {
GetTrades() []domain.ListTrade
GetPendingQuoteRequests() []domain.ListTrade
SendQuote(quoteReqID string, price decimal.Decimal) error
CancelQuote(quoteReqID, text string) error
GetAllMessages(inSeq, outSeq int) []domain.Message
}
const RedisMaxIdle = 3000 // In ms const RedisMaxIdle = 3000 // In ms
type API struct { type API struct {
@ -30,9 +40,13 @@ type Config struct {
AuthorizedServices map[string]app.AuthorizedService `toml:"AuthorizedServices"` AuthorizedServices map[string]app.AuthorizedService `toml:"AuthorizedServices"`
Port string Port string
EnableJWTAuth bool EnableJWTAuth bool
// ServiceAPIKey authenticates internal services (qbymarouter, etc.) calling
// /qfixdpl/v1/quotes and /qfixdpl/v1/messages. Compared against the APIKey
// field in the request body.
ServiceAPIKey string
} }
func New(userData app.UserDataProvider, storeInstance *store.Store, config Config, notify domain.Notifier) *API { func New(userData app.UserDataProvider, storeInstance *store.Store, tradeProvider TradeProvider, config Config, notify domain.Notifier) *API {
// Set up Gin // Set up Gin
var engine *gin.Engine var engine *gin.Engine
if version.Environment() == version.EnvironmentTypeProd { if version.Environment() == version.EnvironmentTypeProd {
@ -58,7 +72,7 @@ func New(userData app.UserDataProvider, storeInstance *store.Store, config Confi
} }
api := &API{ api := &API{
Controller: newController(NewPool(), userData, storeInstance, config, notify), Controller: newController(NewPool(), userData, storeInstance, tradeProvider, config, notify),
Router: engine, Router: engine,
Port: config.Port, Port: config.Port,
} }

View File

@ -0,0 +1,230 @@
// Package fix implements the QuickFIX initiator application.
package fix
import (
"log/slog"
"quantex.com/qfixdpl/quickfix"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionack"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionreport"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteack"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoterequest"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse"
"quantex.com/qfixdpl/quickfix/gen/tag"
"quantex.com/qfixdpl/src/domain"
)
type application struct {
router *quickfix.MessageRouter
notifier domain.Notifier
onLogon func(quickfix.SessionID)
onLogout func(quickfix.SessionID)
onQuote func(quote.Quote, quickfix.SessionID)
onQuoteRequest func(quoterequest.QuoteRequest, quickfix.SessionID)
onQuoteAck func(quoteack.QuoteAck, quickfix.SessionID)
onQuoteResponse func(quoteresponse.QuoteResponse, quickfix.SessionID)
onExecutionReport func(executionreport.ExecutionReport, quickfix.SessionID)
onExecutionAck func(executionack.ExecutionAck, quickfix.SessionID)
onRawMessage func(direction string, msg *quickfix.Message)
}
func newApplication(n domain.Notifier) *application {
app := &application{
router: quickfix.NewMessageRouter(),
notifier: n,
}
app.router.AddRoute(quote.Route(app.handleQuote))
app.router.AddRoute(quoteack.Route(app.handleQuoteAck))
app.router.AddRoute(quoterequest.Route(app.handleQuoteRequest))
app.router.AddRoute(quoteresponse.Route(app.handleQuoteResponse))
app.router.AddRoute(executionack.Route(app.handleExecutionAck))
app.router.AddRoute(executionreport.Route(app.handleExecutionReport))
return app
}
func (a *application) OnCreate(sessionID quickfix.SessionID) {
slog.Info("FIX session created", "session", sessionID.String())
}
func (a *application) OnLogon(sessionID quickfix.SessionID) {
slog.Info("FIX session logged on", "session", sessionID.String())
if a.onLogon != nil {
a.onLogon(sessionID)
}
}
func (a *application) OnLogout(sessionID quickfix.SessionID) {
slog.Info("FIX session logged out", "session", sessionID.String())
go a.notifier.SendMsg(domain.MessageChannelError, "Logout", domain.MessageStatusWarning, nil)
if a.onLogout != nil {
a.onLogout(sessionID)
}
}
func (a *application) ToAdmin(_ *quickfix.Message, _ quickfix.SessionID) {}
func (a *application) ToApp(msg *quickfix.Message, _ quickfix.SessionID) error {
if a.onRawMessage != nil {
a.onRawMessage("OUT", msg)
}
return nil
}
func (a *application) FromAdmin(_ *quickfix.Message, _ quickfix.SessionID) quickfix.MessageRejectError {
return nil
}
func (a *application) FromApp(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
beginString, _ := msg.Header.GetBytes(tag.BeginString)
msgType, _ := msg.Header.GetBytes(tag.MsgType)
var applVerID quickfix.FIXString
msg.Header.GetField(tag.ApplVerID, &applVerID)
slog.Info("FIX FromApp received",
"beginString", string(beginString),
"msgType", string(msgType),
"applVerID", string(applVerID),
"session", sessionID.String(),
"rawMsg", msg.String(),
)
if a.onRawMessage != nil {
a.onRawMessage("IN", msg)
}
rejErr := a.router.Route(msg, sessionID)
if rejErr != nil {
slog.Error("FIX FromApp routing failed",
"msgType", string(msgType),
"error", rejErr.Error(),
"isBusinessReject", rejErr.IsBusinessReject(),
)
}
return rejErr
}
func (a *application) handleQuoteRequest(msg quoterequest.QuoteRequest, sessionID quickfix.SessionID) quickfix.MessageRejectError {
quoteReqID, err := msg.GetQuoteReqID()
if err != nil {
slog.Error("QuoteRequest missing QuoteReqID", "error", err.Error())
return err
}
slog.Info("QuoteRequest received",
"quoteReqID", quoteReqID,
"session", sessionID.String(),
)
if a.onQuoteRequest != nil {
a.onQuoteRequest(msg, sessionID)
}
return nil
}
func (a *application) handleQuoteAck(msg quoteack.QuoteAck, sessionID quickfix.SessionID) quickfix.MessageRejectError {
quoteReqID, _ := msg.GetQuoteReqID()
quoteID, _ := msg.GetQuoteID()
status, _ := msg.GetQuoteAckStatus()
text, _ := msg.GetText()
slog.Info("QuoteAck received",
"quoteReqID", quoteReqID,
"quoteID", quoteID,
"quoteAckStatus", status,
"text", text,
"session", sessionID.String(),
)
if a.onQuoteAck != nil {
a.onQuoteAck(msg, sessionID)
}
return nil
}
func (a *application) handleQuote(msg quote.Quote, sessionID quickfix.SessionID) quickfix.MessageRejectError {
quoteID, err := msg.GetQuoteID()
if err != nil {
return err
}
symbol, _ := msg.GetSymbol()
slog.Info("Quote received", "quoteID", quoteID, "symbol", symbol, "session", sessionID.String())
if a.onQuote != nil {
a.onQuote(msg, sessionID)
}
return nil
}
func (a *application) handleQuoteResponse(msg quoteresponse.QuoteResponse, sessionID quickfix.SessionID) quickfix.MessageRejectError {
quoteRespID, _ := msg.GetQuoteRespID()
quoteReqID, _ := msg.GetQuoteReqID()
quoteRespType, _ := msg.GetQuoteRespType()
slog.Info("QuoteResponse received",
"quoteRespID", quoteRespID,
"quoteReqID", quoteReqID,
"quoteRespType", quoteRespType,
"session", sessionID.String(),
)
if a.onQuoteResponse != nil {
a.onQuoteResponse(msg, sessionID)
}
return nil
}
func (a *application) handleExecutionAck(msg executionack.ExecutionAck, sessionID quickfix.SessionID) quickfix.MessageRejectError {
execID, _ := msg.GetExecID()
orderID, _ := msg.GetOrderID()
status, _ := msg.GetExecAckStatus()
slog.Info("ExecutionAck received",
"execID", execID,
"orderID", orderID,
"execAckStatus", status,
"session", sessionID.String(),
)
if a.onExecutionAck != nil {
a.onExecutionAck(msg, sessionID)
}
return nil
}
func (a *application) handleExecutionReport(msg executionreport.ExecutionReport, sessionID quickfix.SessionID) quickfix.MessageRejectError {
execID, _ := msg.GetExecID()
orderID, _ := msg.GetOrderID()
listID, _ := msg.GetListID()
execType, _ := msg.GetExecType()
ordStatus, _ := msg.GetOrdStatus()
slog.Info("ExecutionReport received",
"execID", execID,
"orderID", orderID,
"listID", listID,
"execType", execType,
"ordStatus", ordStatus,
"session", sessionID.String(),
)
if a.onExecutionReport != nil {
a.onExecutionReport(msg, sessionID)
}
return nil
}

172
src/client/fix/builder.go Normal file
View File

@ -0,0 +1,172 @@
package fix
import (
"fmt"
"quantex.com/qfixdpl/quickfix"
"quantex.com/qfixdpl/quickfix/datadictionary"
)
// BuildFieldMap walks a quickfix.FieldMap and produces an enriched FieldMap
// keyed by FixField (Name + Tag + Type) with values typed per the FIX
// data dictionary. Repeating groups become []FieldMap; nested groups recurse.
func BuildFieldMap(qfMap quickfix.FieldMap, dd *datadictionary.DataDictionary, fieldsByTag map[int]*datadictionary.FieldDef) FieldMap {
out := FieldMap{}
for _, t := range qfMap.Tags() {
tagInt := int(t)
rawValues := qfMap.RawValues(t)
if len(rawValues) == 0 {
continue
}
ff, fd := resolveField(dd, fieldsByTag, tagInt)
if ff.Type == "NUMINGROUP" {
if fd != nil && fd.IsGroup() {
out[ff] = buildGroup(rawValues[1:], dd, fd.Fields)
} else {
// Count tag with no group structure available (dictionary lookup
// failed, or quickfix didn't nest the inner fields). Emit an empty
// group rather than an int so the consumer's type expectation holds.
out[ff] = []FieldMap{}
}
continue
}
out[ff] = parseScalar(rawValues[0].Value(), ff.Type)
}
return out
}
// buildGroup splits a flat slice of TagValues (the inner values of a NUMINGROUP,
// excluding the count) into per-repetition FieldMaps. The first element of
// innerFields is the delimiter that marks the start of each repetition.
func buildGroup(tvs []quickfix.TagValue, dd *datadictionary.DataDictionary, innerFields []*datadictionary.FieldDef) []FieldMap {
if len(innerFields) == 0 || len(tvs) == 0 {
return nil
}
delimiter := innerFields[0].Tag()
fdByTag := make(map[int]*datadictionary.FieldDef, len(innerFields))
for _, f := range innerFields {
fdByTag[f.Tag()] = f
}
var (
repetitions []FieldMap
current FieldMap
)
i := 0
for i < len(tvs) {
tv := tvs[i]
tagInt := int(tv.Tag())
if tagInt == delimiter {
current = FieldMap{}
repetitions = append(repetitions, current)
}
if current == nil {
// Field appeared before the first delimiter; skip.
i++
continue
}
fd, known := fdByTag[tagInt]
if !known {
current[FixField{Name: fmt.Sprintf("tag_%d", tagInt), Tag: tagInt, Type: "STRING"}] = string(tv.Value())
i++
continue
}
if fd.IsGroup() {
nestedAllowed := allowedTags(fd.Fields)
j := i + 1
for j < len(tvs) && nestedAllowed[int(tvs[j].Tag())] {
j++
}
nested := buildGroup(tvs[i+1:j], dd, fd.Fields)
current[FixField{Name: fd.Name(), Tag: tagInt, Type: "NUMINGROUP"}] = nested
i = j
continue
}
current[FixField{Name: fd.Name(), Tag: tagInt, Type: fd.Type}] = parseScalar(tv.Value(), fd.Type)
i++
}
return repetitions
}
// resolveField looks up the FixField metadata (name + type) for a tag.
// Prefers the message-level FieldDef so group structure is preserved;
// falls back to the global FieldTypeByTag, then to a synthetic "tag_<N>" STRING.
func resolveField(dd *datadictionary.DataDictionary, fieldsByTag map[int]*datadictionary.FieldDef, tagInt int) (FixField, *datadictionary.FieldDef) {
if fieldsByTag != nil {
if fd, ok := fieldsByTag[tagInt]; ok {
return FixField{Name: fd.Name(), Tag: tagInt, Type: fd.Type}, fd
}
}
if dd != nil {
if ft, ok := dd.FieldTypeByTag[tagInt]; ok {
return FixField{Name: ft.Name(), Tag: tagInt, Type: ft.Type}, nil
}
}
return FixField{Name: fmt.Sprintf("tag_%d", tagInt), Tag: tagInt, Type: "STRING"}, nil
}
// parseScalar converts raw FIX bytes into the Go type expected by the consumer
// (GetKeyValue): bool for BOOLEAN, int for INT/SEQNUM, time.Time for UTCTIMESTAMP,
// string for everything else.
func parseScalar(raw []byte, fixType string) interface{} {
s := string(raw)
switch fixType {
case "INT", "SEQNUM", "LENGTH":
var v quickfix.FIXInt
if err := v.Read(raw); err == nil {
return int(v)
}
return s
case "BOOLEAN":
var v quickfix.FIXBoolean
if err := v.Read(raw); err == nil {
return bool(v)
}
return s
case "UTCTIMESTAMP":
var v quickfix.FIXUTCTimestamp
if err := v.Read(raw); err == nil {
return v.Time
}
return s
default:
return s
}
}
// allowedTags returns the set of all tags valid inside a group (including
// nested-group counts and their descendants), used to detect where a flat
// nested-group slice ends within its parent.
func allowedTags(fields []*datadictionary.FieldDef) map[int]bool {
out := make(map[int]bool, len(fields))
var visit func(fs []*datadictionary.FieldDef)
visit = func(fs []*datadictionary.FieldDef) {
for _, f := range fs {
out[f.Tag()] = true
if f.IsGroup() {
visit(f.Fields)
}
}
}
visit(fields)
return out
}

106
src/client/fix/dict.go Normal file
View File

@ -0,0 +1,106 @@
package fix
import (
"log/slog"
"time"
"quantex.com/qfixdpl/src/common/tracerr"
)
// FixField identifies a single FIX field by its dictionary metadata.
type FixField struct {
Name string
Tag int
Type string
}
// FieldValue is the typed value for a FIX field.
type FieldValue interface{}
// FieldMap is the enriched representation of a FIX FieldMap (header, body, or trailer).
type FieldMap map[FixField]FieldValue
// GetMap converts a FieldMap into a JSON-friendly map keyed by field name.
func GetMap(fieldMap FieldMap) map[string]interface{} {
result := map[string]interface{}{}
for f, v := range fieldMap {
slog.Info("try to parse fieldMap: %+v, value: %+v", f, v)
k, val := GetKeyValue(f, v)
result[k] = val
}
return result
}
//nolint:funlen,gocyclo,cyclop //it's long but easy to read
func GetKeyValue(f FixField, value FieldValue) (key string, val interface{}) {
key = f.Name
switch f.Type {
case "NUMINGROUP":
var groups []map[string]interface{}
fMapLst, ok := value.([]FieldMap)
if !ok {
err := tracerr.Errorf("could not parse as []FieldMap, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
for _, fieldMap := range fMapLst {
groups = append(groups, GetMap(fieldMap))
}
val = groups
case "BOOLEAN":
b, ok := value.(bool)
if !ok {
err := tracerr.Errorf("could not parse as bool, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = b
case "INT", "LENGTH", "SEQNUM":
i, ok := value.(int)
if !ok {
err := tracerr.Errorf("could not parse as int, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = i
case "UTCTIMESTAMP":
t, ok := value.(time.Time)
if !ok {
err := tracerr.Errorf("could not parse as Time, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = t
case "STRING":
s, ok := value.(string)
if !ok {
err := tracerr.Errorf("could not parse as string, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = s
case "CHAR":
s, ok := value.(string)
if !ok {
err := tracerr.Errorf("could not parse as string, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = s
default:
s, ok := value.(string)
if !ok {
err := tracerr.Errorf("could not parse as string, value: %+v, key: %s", value, key)
slog.Error(err.Error())
}
val = s
}
return key, val
}

822
src/client/fix/manager.go Normal file
View File

@ -0,0 +1,822 @@
package fix
import (
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
uuid "github.com/satori/go.uuid"
"github.com/shopspring/decimal"
"quantex.com/qfixdpl/quickfix"
"quantex.com/qfixdpl/quickfix/datadictionary"
"quantex.com/qfixdpl/quickfix/gen/enum"
"quantex.com/qfixdpl/quickfix/gen/field"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionack"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/executionreport"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteack"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotecancel"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoterequest"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoteresponse"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quotestatusreport"
"quantex.com/qfixdpl/quickfix/gen/tag"
filelog "quantex.com/qfixdpl/quickfix/log/file"
"quantex.com/qfixdpl/quickfix/store/file"
"quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/common/tracerr"
"quantex.com/qfixdpl/src/domain"
)
type listTrade struct {
QuoteRequest domain.FixMessageJSON
SessionID quickfix.SessionID
Quoted bool
Price decimal.Decimal
}
const defaultQuoteCancelText = "Quote withdrawn by dealer"
// Manager wraps the QuickFIX initiator and implements domain.FIXSender.
type Manager struct {
initiator *quickfix.Initiator
app *application
sessionsMu sync.RWMutex
sessions map[string]quickfix.SessionID
tradesMu sync.RWMutex
trades map[string]*listTrade
messagesMu sync.RWMutex
messages []domain.Message
store domain.PersistenceStore
notify domain.Notifier
cfg app.FIXConfig
dict *datadictionary.DataDictionary
}
func NewManager(cfg app.FIXConfig, store domain.PersistenceStore, notify domain.Notifier) *Manager {
return &Manager{
sessions: make(map[string]quickfix.SessionID),
trades: make(map[string]*listTrade),
messages: make([]domain.Message, 0),
store: store,
notify: notify,
cfg: cfg,
}
}
func (m *Manager) Start() error {
fixApp := newApplication(m.notify)
fixApp.onLogon = m.onLogon
fixApp.onLogout = m.onLogout
fixApp.onQuoteRequest = m.handleQuoteRequest
fixApp.onQuoteAck = m.handleQuoteAck
fixApp.onQuoteResponse = m.handleQuoteResponse
fixApp.onExecutionReport = m.handleExecutionReport
fixApp.onExecutionAck = m.handleExecutionAck
fixApp.onRawMessage = m.handleRawMessage
m.app = fixApp
dict, err := datadictionary.Parse(m.cfg.DataDictionaryFile)
if err != nil {
err = tracerr.Errorf("error parsing FIX data dictionary %q: %w", m.cfg.DataDictionaryFile, err)
slog.Error(err.Error())
return err
}
m.dict = dict
if err := m.loadActiveTrades(); err != nil {
err = tracerr.Errorf("failed to load active trades from DB, starting with empty state: %w", err)
slog.Error(err.Error())
}
if err := m.loadTodayMessages(); err != nil {
err = tracerr.Errorf("failed to load today messages from DB, starting with empty list: %w", err)
slog.Error(err.Error())
}
f, err := os.Open(m.cfg.SettingsFile)
if err != nil {
err = tracerr.Errorf("error opening FIX settings file %q: %w", m.cfg.SettingsFile, err)
slog.Error(err.Error())
return err
}
defer f.Close()
settings, err := quickfix.ParseSettings(f)
if err != nil {
err = tracerr.Errorf("error parsing FIX settings: %w", err)
slog.Error(err.Error())
return err
}
storeFactory := file.NewStoreFactory(settings)
logFactory, err := filelog.NewLogFactory(settings)
if err != nil {
err = tracerr.Errorf("error creating file log factory: %w", err)
slog.Error(err.Error())
return err
}
initiator, err := quickfix.NewInitiator(fixApp, storeFactory, settings, logFactory)
if err != nil {
err = tracerr.Errorf("error creating FIX initiator: %w", err)
slog.Error(err.Error())
return err
}
m.initiator = initiator
if err = m.initiator.Start(); err != nil {
err = tracerr.Errorf("error starting FIX initiator: %w", err)
slog.Error(err.Error())
return err
}
slog.Info("FIX initiator started", "settings", m.cfg.SettingsFile)
return nil
}
func (m *Manager) Stop() {
if m.initiator != nil {
m.initiator.Stop()
slog.Info("FIX initiator stopped")
}
}
func (m *Manager) onLogon(sessionID quickfix.SessionID) {
m.sessionsMu.Lock()
m.sessions[sessionID.String()] = sessionID
m.sessionsMu.Unlock()
}
func (m *Manager) onLogout(sessionID quickfix.SessionID) {
m.sessionsMu.Lock()
delete(m.sessions, sessionID.String())
m.sessionsMu.Unlock()
}
// sendQuoteStatusReport sends a QuoteStatusReport (35=AI) to acknowledge the incoming QuoteRequest.
func (m *Manager) sendQuoteStatusReport(quoteReqID, ownerTraderID string, sessionID quickfix.SessionID) error {
qsr := quotestatusreport.New(
field.NewTransactTime(time.Now()),
field.NewQuoteStatus(enum.QuoteStatus_ACCEPTED),
)
qsr.SetQuoteReqID(quoteReqID)
qsr.SetQuoteID(quoteReqID)
qsr.SetSymbol("[N/A]")
if ownerTraderID != "" {
qsr.SetOwnerTraderID(ownerTraderID)
}
return quickfix.SendToTarget(qsr, sessionID)
}
// sendTradeRequestAck sends a QuoteStatusReport (35=AI) to acknowledge a trade request (TRDREQACK).
func (m *Manager) sendTradeRequestAck(quoteReqID, quoteRespID string, sessionID quickfix.SessionID) error {
qsr := quotestatusreport.New(
field.NewTransactTime(time.Now()),
field.NewQuoteStatus(enum.QuoteStatus_ACCEPTED),
)
qsr.SetQuoteReqID(quoteReqID)
qsr.SetQuoteRespID(quoteRespID)
qsr.SetSymbol("[N/A]")
return quickfix.SendToTarget(qsr, sessionID)
}
// sendExecutionAck sends an ExecutionAck (35=BN) to acknowledge an incoming ExecutionReport.
func (m *Manager) sendExecutionAck(orderID, clOrdID, execID string, sessionID quickfix.SessionID) error {
bn := executionack.New(
field.NewOrderID(orderID),
field.NewExecID(execID),
field.NewExecAckStatus(enum.ExecAckStatus_ACCEPTED),
)
bn.SetClOrdID(clOrdID)
bn.SetSymbol("[N/A]")
bn.SetTransactTime(time.Now())
return quickfix.SendToTarget(bn, sessionID)
}
// handleQuoteRequest auto-responds to an incoming QuoteRequest with a QuoteStatusReport (acknowledge).
// The Quote (35=S) is sent later via the REST endpoint when the dealer prices it.
func (m *Manager) handleQuoteRequest(msg quoterequest.QuoteRequest, sessionID quickfix.SessionID) {
parsed := parseQuoteRequest(msg, m.dict)
quoteReqID := parsed.QuoteReqID
if quoteReqID == "" {
m.notify.SendMsg(
domain.MessageChannelError,
"quoteReqID missing in quote request",
domain.MessageStatusWarning,
nil,
)
err := tracerr.Errorf("handleQuoteRequest, missing QuoteReqID, quoteRequest: %+v", parsed)
slog.Error(err.Error())
return
}
// Validate LST_ prefix for List Trading flow.
if !strings.HasPrefix(quoteReqID, "LST_") {
m.notify.SendMsg(
domain.MessageChannelError,
"quoteReqID ("+quoteReqID+") missing LST_ prefix",
domain.MessageStatusWarning,
nil,
)
slog.Warn("handleQuoteRequest: QuoteReqID missing LST_ prefix, ignoring", "quoteReqID", quoteReqID)
return
}
bodyKeys := make([]string, 0, len(parsed.Body))
for k := range parsed.Body {
bodyKeys = append(bodyKeys, k)
}
slog.Info("handleQuoteRequest: parsed body keys", "quoteReqID", quoteReqID, "keys", bodyKeys)
relSym := firstGroup(parsed.Body, "NoRelatedSym")
relSymKeys := make([]string, 0, len(relSym))
for k := range relSym {
relSymKeys = append(relSymKeys, k)
}
slog.Info("handleQuoteRequest: NoRelatedSym keys", "quoteReqID", quoteReqID, "keys", relSymKeys)
ownerTraderID := getString(relSym, "OwnerTraderID")
// Send QuoteStatusReport (35=AI) to acknowledge the inquiry.
if ackErr := m.sendQuoteStatusReport(quoteReqID, ownerTraderID, sessionID); ackErr != nil {
ackErr = tracerr.Errorf("handleQuoteRequest: failed to send QuoteStatusReport (quoteReqID=%s): %w", quoteReqID, ackErr)
slog.Error(ackErr.Error())
return
}
slog.Info("QuoteStatusReport sent", "quoteReqID", quoteReqID)
// Store trade state as pending; Quote (35=S) is sent later via REST endpoint.
m.tradesMu.Lock()
m.trades[quoteReqID] = &listTrade{
QuoteRequest: parsed,
SessionID: sessionID,
Quoted: false,
}
m.tradesMu.Unlock()
}
// handleQuoteAck handles an incoming QuoteAck (35=CW).
func (m *Manager) handleQuoteAck(msg quoteack.QuoteAck, sessionID quickfix.SessionID) {
quoteReqID, _ := msg.GetQuoteReqID()
status, _ := msg.GetQuoteAckStatus()
text, _ := msg.GetText()
if status != enum.QuoteAckStatus_ACCEPTED {
err := tracerr.Errorf("handleQuoteAck: quote rejected by TW (quoteReqID=%s, quoteAckStatus=%s, text=%s)", quoteReqID, string(status), text)
slog.Error(err.Error())
return
}
slog.Info("handleQuoteAck: accepted", "quoteReqID", quoteReqID)
}
// handleQuoteResponse handles an incoming QuoteResponse (35=AJ).
// Supports _TRDREQ (trade request), _TRDEND (trade ended), and _TRDSUMM (trade summary) suffixes.
func (m *Manager) handleQuoteResponse(msg quoteresponse.QuoteResponse, sessionID quickfix.SessionID) {
quoteReqID, _ := msg.GetQuoteReqID()
quoteRespID, _ := msg.GetQuoteRespID()
slog.Info("handleQuoteResponse", "quoteReqID", quoteReqID, "quoteRespID", quoteRespID)
isTrdReq := strings.HasSuffix(quoteRespID, "_TRDREQ")
isTrdEnd := strings.HasSuffix(quoteRespID, "_TRDEND")
isTrdSumm := strings.HasSuffix(quoteRespID, "_TRDSUMM")
isListEnd := strings.HasSuffix(quoteRespID, "_LISTEND")
if !isTrdReq && !isTrdEnd && !isTrdSumm && !isListEnd {
slog.Info("handleQuoteResponse: QuoteRespID has unrecognized suffix, ignoring", "quoteRespID", quoteRespID)
return
}
// TODO: handle 694=2 (Counter) for flow 8.5 (Client Countering) in the future.
// Always send ACK regardless of whether the trade is in our map.
// TW will keep retrying until it receives an ACK.
if ackErr := m.sendTradeRequestAck(quoteReqID, quoteRespID, sessionID); ackErr != nil {
ackErr = tracerr.Errorf("handleQuoteResponse: failed to send ACK (quoteReqID=%s, quoteRespID=%s): %w", quoteReqID, quoteRespID, ackErr)
slog.Error(ackErr.Error())
return
}
slog.Info("QuoteResponse ACK sent", "quoteReqID", quoteReqID, "quoteRespID", quoteRespID)
// _TRDSUMM is the final message — clean up the trade.
if isTrdSumm {
slog.Info("Trade summary received, cleaning up", "quoteReqID", quoteReqID)
}
}
// handleExecutionReport handles an incoming ExecutionReport (35=8).
// In flow 8.4 (List Trading), the dealer NEVER sends an ExecutionReport — only TW does.
func (m *Manager) handleExecutionReport(msg executionreport.ExecutionReport, sessionID quickfix.SessionID) {
execID, _ := msg.GetExecID()
orderID, _ := msg.GetOrderID()
clOrdID, _ := msg.GetClOrdID()
execType, _ := msg.GetExecType()
ordStatus, _ := msg.GetOrdStatus()
listID, _ := msg.GetListID()
slog.Info("handleExecutionReport received",
"execID", execID, "orderID", orderID, "clOrdID", clOrdID,
"execType", string(execType), "ordStatus", string(ordStatus), "listID", listID,
)
// Send ExecutionAck (35=BN) for every incoming ExecutionReport from TW.
if ackErr := m.sendExecutionAck(orderID, clOrdID, execID, sessionID); ackErr != nil {
ackErr = tracerr.Errorf("handleExecutionReport: failed to send ExecutionAck (execID=%s): %w", execID, ackErr)
slog.Error(ackErr.Error())
} else {
slog.Info("ExecutionAck sent", "execID", execID)
}
switch {
case strings.Contains(execID, "_LISTEND"):
slog.Info("List ended (due-in closed), awaiting trade result from TW",
"execID", execID, "clOrdID", clOrdID)
case strings.Contains(execID, "_TRDEND"):
slog.Info("Trade ended", "execID", execID, "clOrdID", clOrdID)
case strings.Contains(execID, "_TRDSUMM"):
slog.Info("Trade summary received from TW, cleaning up",
"execID", execID, "clOrdID", clOrdID, "ordStatus", string(ordStatus), "listID", listID)
case execType == enum.ExecType_TRADE:
slog.Info("Trade result received from TW",
"execID", execID, "clOrdID", clOrdID, "ordStatus", string(ordStatus), "listID", listID)
}
}
// handleExecutionAck handles an incoming ExecutionAck (35=BN) from TW.
func (m *Manager) handleExecutionAck(msg executionack.ExecutionAck, sessionID quickfix.SessionID) {
// Logged in application.go, no further action needed.
}
// GetTrades returns a snapshot of all active trades.
func (m *Manager) GetTrades() []domain.ListTrade {
m.tradesMu.RLock()
defer m.tradesMu.RUnlock()
trades := make([]domain.ListTrade, 0, len(m.trades))
for _, t := range m.trades {
trades = append(trades, toDomainListTrade(t))
}
return trades
}
// GetPendingQuoteRequests returns trades that have received a QuoteRequest but not yet been quoted by the dealer.
func (m *Manager) GetPendingQuoteRequests() []domain.ListTrade {
m.tradesMu.RLock()
defer m.tradesMu.RUnlock()
pending := make([]domain.ListTrade, 0)
for _, t := range m.trades {
pending = append(pending, toDomainListTrade(t))
}
return pending
}
func toDomainListTrade(t *listTrade) domain.ListTrade {
out := domain.ListTrade{
QuoteRequest: t.QuoteRequest,
}
return out
}
// SendQuote builds and sends a Quote (35=S) for an existing pending QuoteRequest at the given price.
func (m *Manager) SendQuote(quoteReqID string, price decimal.Decimal) error {
m.tradesMu.Lock()
t, ok := m.trades[quoteReqID]
if !ok {
m.tradesMu.Unlock()
err := tracerr.Errorf("SendQuote: quoteReqID %s not found", quoteReqID)
slog.Error(err.Error())
return err
}
sessionID := t.SessionID
if sessionID == (quickfix.SessionID{}) {
sessionID = m.anyActiveSessionID()
if sessionID == (quickfix.SessionID{}) {
m.tradesMu.Unlock()
err := tracerr.Errorf("SendQuote: no active FIX session for quoteReqID %s", quoteReqID)
slog.Error(err.Error())
return err
}
}
symbol := getString(t.QuoteRequest.Body, "SecurityID")
sIDSource := enum.SecurityIDSource(getString(t.QuoteRequest.Body, "SecurityIDSource"))
currency := getString(t.QuoteRequest.Body, "Currency")
side := enum.Side(getString(t.QuoteRequest.Body, "Side"))
orderQty := getDecimal(t.QuoteRequest.Body, "OrderQty")
settlDate := getString(t.QuoteRequest.Body, "SettlDate")
ownerTraderID := getString(t.QuoteRequest.Body, "OwnerTraderID")
m.tradesMu.Unlock()
if sIDSource != enum.SecurityIDSource_CUSIP {
sIDSource = enum.SecurityIDSource_ISIN_NUMBER
}
quoteID := quoteReqID
q := quote.New(
field.NewQuoteID(quoteID),
field.NewQuoteType(enum.QuoteType_SEND_QUOTE),
field.NewTransactTime(time.Now()),
)
q.SetSymbol("[N/A]")
q.SetSecurityID(symbol)
q.SetSecurityIDSource(sIDSource)
q.SetQuoteReqID(quoteReqID)
if currency != "" {
q.SetCurrency(currency)
}
if !orderQty.IsZero() {
q.SetOrderQty(orderQty, 0)
}
if settlDate != "" {
q.SetSettlDate(settlDate)
}
q.SetPrice(price, 8)
if side == enum.Side_BUY {
q.SetOfferPx(price, 8)
q.SetSide(enum.Side_BUY)
} else {
q.SetBidPx(price, 8)
q.SetSide(enum.Side_SELL)
}
q.SetPriceType(enum.PriceType_PERCENTAGE)
if ownerTraderID != "" {
q.SetOwnerTraderID(ownerTraderID)
}
if sendErr := quickfix.SendToTarget(q, sessionID); sendErr != nil {
sendErr = tracerr.Errorf("SendQuote: failed to send quote (quoteReqID=%s): %w", quoteReqID, sendErr)
slog.Error(sendErr.Error())
return sendErr
}
m.tradesMu.Lock()
if t, ok := m.trades[quoteReqID]; ok {
t.Price = price
t.Quoted = true
}
m.tradesMu.Unlock()
slog.Info("Quote sent", "quoteReqID", quoteReqID, "quoteID", quoteID, "symbol", symbol, "price", price.String())
return nil
}
// CancelQuote builds and sends a QuoteCancel (35=Z) for an existing QuoteRequest.
func (m *Manager) CancelQuote(quoteReqID, text string) error {
m.tradesMu.RLock()
t, ok := m.trades[quoteReqID]
if !ok {
m.tradesMu.RUnlock()
err := tracerr.Errorf("CancelQuote: quoteReqID %s not found", quoteReqID)
slog.Error(err.Error())
return err
}
sessionID := t.SessionID
if sessionID == (quickfix.SessionID{}) {
sessionID = m.anyActiveSessionID()
if sessionID == (quickfix.SessionID{}) {
m.tradesMu.RUnlock()
err := tracerr.Errorf("CancelQuote: no active FIX session for quoteReqID %s", quoteReqID)
slog.Error(err.Error())
return err
}
}
quoteRequestBody := t.QuoteRequest.Body
m.tradesMu.RUnlock()
ownerTraderID := quoteRequestString(quoteRequestBody, "OwnerTraderID")
canRespond, hasCanRespond, rawCanRespond := quoteRequestCanRespond(quoteRequestBody)
canRespondLogValue := rawCanRespond
if canRespondLogValue == "" {
canRespondLogValue = "missing"
}
if !hasCanRespond {
slog.Warn("CancelQuote: CanRespond missing or unparseable, sending QuoteCancel anyway",
"quoteReqID", quoteReqID,
"canRespond", canRespondLogValue,
)
} else if !canRespond {
err := tracerr.Errorf("CancelQuote: quoteReqID %s cannot respond", quoteReqID)
slog.Error(err.Error(),
"quoteReqID", quoteReqID,
"canRespond", canRespondLogValue,
)
return err
} else {
slog.Info("CancelQuote: CanRespond allows QuoteCancel",
"quoteReqID", quoteReqID,
"canRespond", canRespondLogValue,
)
}
quoteID := uuid.NewV4().String()
text = strings.TrimSpace(text)
if text == "" {
text = defaultQuoteCancelText
}
qc := quotecancel.New(
field.NewQuoteReqID(quoteReqID),
field.NewQuoteCancelType(enum.QuoteCancelType_CANCEL_SPECIFIED_SINGLE_QUOTE),
)
qc.SetQuoteID(quoteID)
qc.SetTransactTime(time.Now().UTC())
qc.SetText(text)
if ownerTraderID != "" {
qc.SetOwnerTraderID(ownerTraderID)
}
if sendErr := quickfix.SendToTarget(qc, sessionID); sendErr != nil {
sendErr = tracerr.Errorf("CancelQuote: failed to send quote cancel (quoteReqID=%s, quoteID=%s): %w", quoteReqID, quoteID, sendErr)
slog.Error(sendErr.Error())
return sendErr
}
slog.Info("QuoteCancel sent",
"quoteReqID", quoteReqID,
"quoteID", quoteID,
"canRespond", canRespondLogValue,
"ownerTraderID", ownerTraderID,
"text", text,
)
return nil
}
// firstGroup returns the first repetition of a NUMINGROUP field, or nil.
func firstGroup(body map[string]any, name string) map[string]any {
if body == nil {
return nil
}
switch groups := body[name].(type) {
case []map[string]any:
if len(groups) == 0 {
return nil
}
return groups[0]
case []any:
if len(groups) == 0 {
return nil
}
group, _ := groups[0].(map[string]any)
return group
}
return nil
}
// getString reads a string value from a body map, tolerating nil maps and missing keys.
func getString(body map[string]any, name string) string {
if body == nil {
return ""
}
if v, ok := body[name].(string); ok {
return v
}
return ""
}
func quoteRequestString(body map[string]any, name string) string {
if v := getString(body, name); v != "" {
return v
}
return getString(firstGroup(body, "NoRelatedSym"), name)
}
func quoteRequestCanRespond(body map[string]any) (bool, bool, string) {
if value, ok := body["CanRespond"]; ok {
return parseCanRespondValue(value)
}
if value, ok := firstGroup(body, "NoRelatedSym")["CanRespond"]; ok {
return parseCanRespondValue(value)
}
return false, false, ""
}
func parseCanRespondValue(value any) (bool, bool, string) {
switch v := value.(type) {
case bool:
if v {
return true, true, "true"
}
return false, true, "false"
case string:
normalized := strings.ToUpper(strings.TrimSpace(v))
switch normalized {
case "Y", "YES", "TRUE", "1":
return true, true, v
case "N", "NO", "FALSE", "0":
return false, true, v
default:
return false, false, v
}
case int:
return v != 0, true, fmt.Sprint(v)
case int64:
return v != 0, true, fmt.Sprint(v)
case float64:
return v != 0, true, fmt.Sprint(v)
default:
return false, false, ""
}
}
// getDecimal reads a decimal value from a body map. Numeric FIX types come through
// as strings (e.g. "10000"); INT-typed counts may be int. Both are accepted.
func getDecimal(body map[string]any, name string) decimal.Decimal {
if body == nil {
return decimal.Decimal{}
}
switch v := body[name].(type) {
case string:
d, _ := decimal.NewFromString(v)
return d
case int:
return decimal.NewFromInt(int64(v))
}
return decimal.Decimal{}
}
func (m *Manager) anyActiveSessionID() quickfix.SessionID {
m.sessionsMu.RLock()
defer m.sessionsMu.RUnlock()
for _, s := range m.sessions {
return s
}
return quickfix.SessionID{}
}
// handleRawMessage is the single ingest path for every application-level FIX message
// (admin messages — Logon, Logout, Heartbeat, TestRequest, ResendRequest, SequenceReset —
// go through ToAdmin/FromAdmin and never reach this callback). It persists the raw
// envelope to the logs table, builds a structured Message and saves it to
// the messages table, and appends to the in-memory list.
func (m *Manager) handleRawMessage(direction string, msg *quickfix.Message) {
quoteReqID := extractIdentifier(msg)
if err := m.store.SaveLog(domain.LogEntry{
QuoteReqID: quoteReqID,
RawMsg: "[" + direction + "] " + msg.String(),
}); err != nil {
err = tracerr.Errorf("failed to persist raw log: %w", err)
slog.Error(err.Error())
}
msgTypeBytes, _ := msg.Header.GetBytes(tag.MsgType)
msgType := string(msgTypeBytes)
senderCompID, msgSeqNum, sendingTime := extractHeaderMeta(msg)
fixJSON := buildFixMessageJSON(direction, msgType, quoteReqID, msg, m.dict)
stored := domain.Message{
ID: uuid.NewV4().String(),
SenderCompID: senderCompID,
MsgSeqNum: msgSeqNum,
SendingTime: sendingTime,
CreatedAt: time.Now(),
JMessage: fixJSON,
}
if err := m.store.SaveMessage(stored); err != nil {
err = tracerr.Errorf("failed to persist message (msgType=%s, quoteReqID=%s): %w", msgType, quoteReqID, err)
slog.Error(err.Error())
}
m.messagesMu.Lock()
m.messages = append(m.messages, stored)
m.messagesMu.Unlock()
}
// GetAllMessages returns today's FIX application messages with MsgSeqNum greater than
// the caller's last-seen sequence per direction (inSeq for IN, outSeq for OUT), sorted
// ascending by CreatedAt. Passing 0 for either cursor returns all messages on that side.
func (m *Manager) GetAllMessages(inSeq, outSeq int) []domain.Message {
m.messagesMu.RLock()
log.Info().Msgf("request received, inSeq: %d, outSeq: %d", inSeq, outSeq)
filtered := make([]domain.Message, 0, len(m.messages))
for _, msg := range m.messages {
switch msg.JMessage.Direction {
case "IN":
if msg.MsgSeqNum > inSeq {
filtered = append(filtered, msg)
}
case "OUT":
if msg.MsgSeqNum > outSeq {
filtered = append(filtered, msg)
}
}
}
m.messagesMu.RUnlock()
sort.Slice(filtered, func(i, j int) bool { return filtered[i].CreatedAt.Before(filtered[j].CreatedAt) })
log.Info().Msgf("messages sent: %d", len(filtered))
return filtered
}
// loadTodayMessages rebuilds the in-memory message list from today's rows in the DB.
// Must be called before the FIX initiator starts so live ingest doesn't race with replay.
func (m *Manager) loadTodayMessages() error {
messages, err := m.store.GetTodayMessages()
if err != nil {
return err
}
m.messagesMu.Lock()
m.messages = messages
m.messagesMu.Unlock()
slog.Info("today messages loaded", "count", len(messages))
return nil
}
// loadActiveTrades reconstructs active trades from today's messages in the database.
func (m *Manager) loadActiveTrades() error {
messages, err := m.store.GetTodayMessages()
if err != nil {
return err
}
activeTrades := make(map[string]*listTrade)
for _, msg := range messages {
quoteReqID := msg.JMessage.QuoteReqID
switch msg.JMessage.MsgType {
case "R": // QuoteRequest -> trade is born
if !strings.HasPrefix(quoteReqID, "LST_") {
continue
}
relSym := firstGroup(msg.JMessage.Body, "NoRelatedSym")
if getString(relSym, "NegotiationType") != "RFQ" {
continue
}
if getString(relSym, "ListID") == "" {
continue
}
activeTrades[quoteReqID] = &listTrade{
QuoteRequest: msg.JMessage,
}
case "S": // Outgoing Quote — dealer has already quoted this trade
if t, ok := activeTrades[quoteReqID]; ok {
t.Quoted = true
t.Price = getDecimal(msg.JMessage.Body, "Price")
}
}
}
m.trades = activeTrades
slog.Info("recovery completed", "activeTrades", len(activeTrades))
return nil
}

View File

@ -0,0 +1,82 @@
package fix
import "testing"
func TestQuoteRequestCanRespond(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body map[string]any
want bool
wantOK bool
wantRaw string
}{
{
name: "top-level bool true",
body: map[string]any{"CanRespond": true},
want: true,
wantOK: true,
wantRaw: "true",
},
{
name: "group string yes",
body: map[string]any{
"NoRelatedSym": []map[string]any{{"CanRespond": "Y"}},
},
want: true,
wantOK: true,
wantRaw: "Y",
},
{
name: "json decoded group string yes",
body: map[string]any{
"NoRelatedSym": []any{map[string]any{"CanRespond": "Y"}},
},
want: true,
wantOK: true,
wantRaw: "Y",
},
{
name: "group false",
body: map[string]any{
"NoRelatedSym": []map[string]any{{"CanRespond": false}},
},
want: false,
wantOK: true,
wantRaw: "false",
},
{
name: "missing",
body: map[string]any{},
want: false,
wantOK: false,
wantRaw: "",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, gotOK, gotRaw := quoteRequestCanRespond(tc.body)
if got != tc.want || gotOK != tc.wantOK || gotRaw != tc.wantRaw {
t.Fatalf("quoteRequestCanRespond() = (%v, %v, %q), want (%v, %v, %q)",
got, gotOK, gotRaw, tc.want, tc.wantOK, tc.wantRaw)
}
})
}
}
func TestQuoteRequestStringFallsBackToNoRelatedSym(t *testing.T) {
t.Parallel()
body := map[string]any{
"NoRelatedSym": []map[string]any{{"OwnerTraderID": "dealer-1"}},
}
if got := quoteRequestString(body, "OwnerTraderID"); got != "dealer-1" {
t.Fatalf("quoteRequestString() = %q, want dealer-1", got)
}
}

85
src/client/fix/parser.go Normal file
View File

@ -0,0 +1,85 @@
package fix
import (
"time"
"quantex.com/qfixdpl/quickfix"
"quantex.com/qfixdpl/quickfix/datadictionary"
"quantex.com/qfixdpl/quickfix/gen/fix50sp2/quoterequest"
"quantex.com/qfixdpl/quickfix/gen/tag"
"quantex.com/qfixdpl/src/domain"
)
// buildFixMessageJSON walks the full FIX message (header + body + trailer)
// using the data dictionary and returns a fully populated FixMessageJSON.
func buildFixMessageJSON(direction, msgType, quoteReqID string, msg *quickfix.Message, dd *datadictionary.DataDictionary) domain.FixMessageJSON {
var (
headerFields map[int]*datadictionary.FieldDef
trailerFields map[int]*datadictionary.FieldDef
bodyFields map[int]*datadictionary.FieldDef
)
if dd != nil {
if dd.Header != nil {
headerFields = dd.Header.Fields
}
if dd.Trailer != nil {
trailerFields = dd.Trailer.Fields
}
if md, ok := dd.Messages[msgType]; ok {
bodyFields = md.Fields
}
}
return domain.FixMessageJSON{
Direction: direction,
MsgType: msgType,
QuoteReqID: quoteReqID,
Header: GetMap(BuildFieldMap(msg.Header.FieldMap, dd, headerFields)),
Body: GetMap(BuildFieldMap(msg.Body.FieldMap, dd, bodyFields)),
Trailer: GetMap(BuildFieldMap(msg.Trailer.FieldMap, dd, trailerFields)),
ReceiveTime: time.Now(),
}
}
func parseQuoteRequest(msg quoterequest.QuoteRequest, dd *datadictionary.DataDictionary) domain.FixMessageJSON {
quoteReqID, _ := msg.GetQuoteReqID()
return buildFixMessageJSON("IN", "R", quoteReqID, msg.Message, dd)
}
// extractIdentifier extracts the trade identifier from a parsed FIX message.
// For ExecutionReport (8) and ExecutionAck (BN), uses ClOrdID (tag 11).
// For all other message types, uses QuoteReqID (tag 131).
func extractIdentifier(msg *quickfix.Message) string {
msgType, _ := msg.Header.GetBytes(tag.MsgType)
switch string(msgType) {
case "8", "BN":
var clOrdID quickfix.FIXString
if err := msg.Body.GetField(tag.ClOrdID, &clOrdID); err == nil {
return string(clOrdID)
}
default:
var quoteReqID quickfix.FIXString
if err := msg.Body.GetField(tag.QuoteReqID, &quoteReqID); err == nil {
return string(quoteReqID)
}
}
return ""
}
// extractHeaderMeta reads SenderCompID (49), MsgSeqNum (34) and SendingTime (52)
// from a quickfix.Message header. Returns zero values when a field is absent.
func extractHeaderMeta(msg *quickfix.Message) (senderCompID string, msgSeqNum int, sendingTime time.Time) {
if s, err := msg.Header.GetString(tag.SenderCompID); err == nil {
senderCompID = s
}
if n, err := msg.Header.GetInt(tag.MsgSeqNum); err == nil {
msgSeqNum = n
}
if t, err := msg.Header.GetTime(tag.SendingTime); err == nil {
sendingTime = t
}
return
}

104
src/client/fix/protocol.txt Normal file
View File

@ -0,0 +1,104 @@
Step 1
Direction: ← QuoteRequest (R)
Comentary: Tradeweb sends trade information to dealer.
FIX Message: 8=FIXT.1.1 9=869 35=R 34=2 49=TRADEWEB 52=20160401-16:53:19.992 56=TW1_CORI_TEST_12345_DLRDPL 131=LST_20160401_TW1_CORI_NY302485.18_1 146=1 55=AMXLMM 2.375 09/08/16 48=02364WBC8 22=1 460=12 167=CORP 762=REGCORIPRC 541=20160908 225=20110908 470=MX 223=2.375 106=AMERICA MOVIL SAB DE CV 54=2 38=1000000 64=20160406 15=USD 6110=Comms 60=20160401-16:53:19 662=99.98046875 22570=0.76 663=1 699=912828UR9 761=1 423=6 44=-999999 5023=0.001000 66=NY302485.18 6847=1 75=20160401 464=Y 20086=1 20074=Y 20075=Y 20077=LatAm Comms 20078=pddealer 20079=60 20081=60 20090=60 20072=60 20098=60 5745=1 20073=RFQ 20076=Y 20156=N 20130=2000000000 20138=JPM,MER 20175=20120308 2115=0 22630=0 20265=LatAm 453=3 448=emack 447=C 452=3 802=3 523=Dev Test 803=2 523=NY 803=25 523=USA 803=4000 448=Tradeweb 447=C 452=1 802=1 523=Tradeweb0001 803=4002 448=DTCC 447=C 452=4 5114=2 5113=1 20169=A2 5113=0 20169=A- 10=121
Step 2
Direction: QuoteStatusReport (AI) →
Comentary: Dealer acknowledges trade.
FIX Message: 8=FIXT.1.1 9=198 35=AI 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=2 52=20160401-16:53:19.988 131=LST_20160401_TW1_CORI_NY302485.18_1 117=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 60=20160401-16:53:19.988 297=0 10=106
Step 3
Direction: Quote (S) →
Comentary: Dealer sends quote.
FIX Message: 8=FIXT.1.1 9=231 35=S 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=3 52=20160401-16:53:19.990 117=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 54=2 131=LST_20160401_TW1_CORI_NY302485.18_1 537=211 6153=emackdlr 44=.99 423=6 60=20160401-16:53:19.990 10=247
Step 4
Direction: ← QuoteAck (CW)
Comentary: Tradeweb acknowledges quote.
FIX Message: 8=FIXT.1.1 9=186 35=CW 34=3 49=TRADEWEB 52=20160401-16:53:25.102 56=TW1_CORI_TEST_12345_DLRDPL 60=20160401-16:53:25 117=LST_20160401_TW1_CORI_NY302485.18_1 131=LST_20160401_TW1_CORI_NY302485.18_1 1865=1 10=154
Step 5
Direction: Quote (S) →
Comentary: Dealer sends quote.
FIX Message: 8=FIXT.1.1 9=239 35=S 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=4 52=20160401-16:53:19.990 117=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 54=2 131=LST_20160401_TW1_CORI_NY302485.18_1 537=211 20087=5 6153=emackdlr 44=.97 423=6 60=20160401-16:53:19.990 10=114
Step 6
Direction: ← QuoteAck (CW)
Comentary: Tradeweb acknowledges quote.
FIX Message: 8=FIXT.1.1 9=186 35=CW 34=4 49=TRADEWEB 52=20160401-16:53:30.055 56=TW1_CORI_TEST_12345_DLRDPL 60=20160401-16:53:30 117=LST_20160401_TW1_CORI_NY302485.18_1 131=LST_20160401_TW1_CORI_NY302485.18_1 1865=1 10=154
Step 7
Direction: Quote (S) →
Comentary: Dealer sends quote.
FIX Message: 8=FIXT.1.1 9=239 35=S 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=6 52=20160401-16:53:19.990 117=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 54=2 131=LST_20160401_TW1_CORI_NY302485.18_1 537=211 20087=5 6153=emackdlr 44=.98 423=6 60=20160401-16:53:19.990 10=117
Step 8
Direction: ← QuoteAck (CW)
Commentary: Tradeweb acknowledges quote.
FIX Message: 8=FIXT.1.1 9=186 35=CW 34=5 49=TRADEWEB 52=20160401-16:53:35.071 56=TW1_CORI_TEST_12345_DLRDPL 60=20160401-16:53:35 117=LST_20160401_TW1_CORI_NY302485.18_1 131=LST_20160401_TW1_CORI_NY302485.18_1 1865=1 10=163
Step 9
Direction: ← ExecutionReport (8)
Commentary: Tradeweb notifies dealer that the list trading (Due In time) has ended. ExecType=A
FIX Message: 8=FIXT.1.1 9=289 35=8 34=7 49=TRADEWEB 52=20160401-16:54:19.463 56=TW1_CORI_TEST_12345_DLRDPL 6=0 11=LST_20160401_TW1_CORI_NY302485.18_1 14=0 17=LST_20160401_TW1_CORI_NY302485.18_1_LISTEND-125419.463 37=LST_20160401_TW1_CORI_NY302485.18_1 39=D 55=[N/A] 60=20160401-16:54:19 75=20160401 150=I 151=0 20086=1 10=073
Step 10
Direction: ExecutionAck (BN) →
Commentary: Dealer acknowledges ExecutionReport message.
FIX Message: 8=FIXT.1.1 9=255 35=BN 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=8 52=20160401-16:54:19.448 1036=1 37=LST_20160401_TW1_CORI_NY302485.18_1 11=LST_20160401_TW1_CORI_NY302485.18_1 17=LST_20160401_TW1_CORI_NY302485.18_1_LISTEND-125419.448 55=[N/A] 60=20160401-16:54:19.448 10=119
Step 11
Direction: ← QuoteResponse (AJ)
Commentary: Customer lifts after good-for time expires. Tradeweb informs dealer customer lift.
FIX Message: 8=FIXT.1.1 9=431 35=AJ 34=10 49=TRADEWEB 52=20160401-16:55:32.855 56=TW1_CORI_TEST_12345_DLRDPL 11=LST_20160401_TW1_CORI_NY302485.18_1 22=1 38=1000000 44=0.98 48=02364WBC8 54=2 55=[N/A] 60=20160401-16:55:32 117=LST_20160401_TW1_CORI_NY302485.18_1 131=LST_20160401_TW1_CORI_NY302485.18_1 423=6 662=99.98046875 693=LST_20160401_TW1_CORI_NY302485.18_1_TRDREQ 694=1 2115=1 20074=Y 20075=N 20076=N 20079=60 20082=60 20156=N 22570=0.760289080299 22630=0 10=183
Step 12
Direction: QuoteStatusReport (AI) →
Commentary: Dealer acknowledges customers trade acceptance.
FIX Message: 8=FIXT.1.1 9=206 35=AI 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=12 52=20160401-16:55:32.849 693=LST_20160401_TW1_CORI_NY302485.18_1_TRDREQ 131=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 60=20160401-16:55:32.849 297=0 10=189
Step 13
Direction: ← ExecutionReport (8)
Commentary: Tradeweb notifies the dealer that list has ended
FIX Message: incoming8=FIXT.1.1 9=290 35=8 34=11 49=TRADEWEB 52=20160401-16:55:32.855 56=TW1_CORI_TEST_12345_DLRDPL 6=0 11=LST_20160401_TW1_CORI_NY302485.18_1 14=0 17=LST_20160401_TW1_CORI_NY302485.18_1_LISTEND-125532.855 37=LST_20160401_TW1_CORI_NY302485.18_1 39=A 55=[N/A] 60=20160401-16:55:32 75=20160401 150=A 151=0 20086=1 10=095
Step 14
Direction: ExecutionAck (BN) →
Commentary: Dealer acknowledges ExecutionReport message.
FIX Message: 8=FIXT.1.1 9=256 35=BN 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=13 52=20160401-16:55:32.852 1036=1 37=LST_20160401_TW1_CORI_NY302485.18_1 11=LST_20160401_TW1_CORI_NY302485.18_1 17=LST_20160401_TW1_CORI_NY302485.18_1_LISTEND-125532.852 55=[N/A] 60=20160401-16:55:32.852 10=155
Step 15
Direction: ExecutionReport (8) →
Commentary: Dealer accepts re-quote is not allowed. ExecType=F, OrdStatus=Filled
FIX Message: 8=FIXT.1.1 9=283 35=8 34=14 49=TW1_CORI_TEST_12345_DLRDPL 52=20160401-16:55:32.850 56=TRADEWEB 6=0 14=0 17=LST_20160401_TW1_CORI_NY302485.18_1 37=LST_20160401_TW1_CORI_NY302485.18_1 39=2 54=2 55=[N/A] 150=F 151=0 6153=LST_20160401_TW1_CORI_NY302485.18_1 22631=POSTTRADE_STRING 22632=POSTTRADE_STRING 10=155
Step 16
Direction: ← ExecutionAck (BN)
Commentary: Tradeweb acknowledges ExecutionReport
FIX Message: 8=FIXT.1.1 9=233 35=BN 34=12 49=TRADEWEB 52=20160401-16:55:37.917 56=TW1_CORI_TEST_12345_DLRDPL 11=LST_20160401_TW1_CORI_NY302485.18_1 17=LST_20160401_TW1_CORI_NY302485.18_1 37=LST_20160401_TW1_CORI_NY302485.18_1 55=[N/A] 60=20160401-16:55:37 1036=1 10=051
Step 17
Direction: ← ExecutionReport (8)
Commentary: Tradeweb informs Dealer of outcome of a list trade item. OrdStatus=Filled, ExecType=Trade
FIX Message: 8=FIXT.1.1 9=337 35=8 34=13 49=TRADEWEB 52=20160401-16:55:37.917 56=TW1_CORI_TEST_12345_DLRDPL 6=0 11=LST_20160401_TW1_CORI_NY302485.18_1 14=0 17=LST_20160401_TW1_CORI_NY302485.18_1_TRDEND-125537.917 37=LST_20160401_TW1_CORI_NY302485.18_1 39=2 44=0.98 54=2 55=[N/A] 60=20160401-16:55:37 75=20160401 150=F 151=0 423=6 662=99.98046875 22570=0.760289080299 10=067
Step 18
Direction: ExecutionAck (BN) →
Comentary: Dealer acknowledges ExecutionReport message.
FIX Message: 8=FIXT.1.1 9=255 35=BN 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=15 52=20160401-16:55:37.910 1036=1 37=LST_20160401_TW1_CORI_NY302485.18_1 11=LST_20160401_TW1_CORI_NY302485.18_1 17=LST_20160401_TW1_CORI_NY302485.18_1_TRDEND-125537.917 55=[N/A] 60=20160401-16:55:37.910 10=078
Step 19
Direction:
Comentary: Autospot at Tradeweb Treasury composite.
FIX Message:
Step 20
Direction: ← ExecutionReport (8)
Comentary: Tradeweb sends ExecutionReport message to confirm final outcome of list trade item including applicable cover quote, spot, settlement money information, etc. OrdStatus=Filled, ExecType=Order Status TradeSummary = Y
FIX Message: 8=FIXT.1.1 9=733 35=8 34=14 49=TRADEWEB 52=20160401-16:55:40.292 56=TW1_CORI_TEST_12345_DLRDPL 6=0 11=LST_20160401_TW1_CORI_NY302485.18_1 14=0 17=LST_20160401_TW1_CORI_NY302485.18_1_TRDSUMM-125540.277 22=1 37=LST_20160401_TW1_CORI_NY302485.18_1 38=1000000 39=2 44=0.98 48=02364WBC8 54=2 55=[N/A] 60=20160401-16:55:40 64=20160406 75=20160401 150=F 151=0 167=CORP 236=1.74 423=6 453=2 448=emack 447=C 452=3 802=3 523=Dev Test 803=2 523=NY 803=25 523=USA 803=4000 448=Tradeweb 447=C 452=1 802=2 523=Tradeweb0001 803=4002 523=YES 803=4003 526=TRD_20160401_TW1_CORI_23 662=99.98046875 1003=20160401.TW1.CORI.23 6153=emackdlr 6731=20160401.TW1.CORI.23 20115=100.265 20250=225000 22570=0.76 22630=0 22631=POSTTRADE_STRING 22634=160401.DLRX.TRSY.120 22636=Y 10=239
Step 21
Direction: ExecutionAck (BN) →
Comentary: Dealer acknowledges ExecutionReport message.
FIX Message: 8=FIXT.1.1 9=256 35=BN 49=TW1_CORI_TEST_12345_DLRDPL 56=TRADEWEB 34=16 52=20160401-16:55:40.287 1036=1 37=LST_20160401_TW1_CORI_NY302485.18_1 11=LST_20160401_TW1_CORI_NY302485.18_1 17=LST_20160401_TW1_CORI_NY302485.18_1_TRDSUMM-125540.277 55=[N/A] 60=20160401-16:55:40.287 10=182

View File

@ -132,7 +132,7 @@ func getMessage(text string, status domain.MessageStatus) string {
'cardId': 'createCardMessage', 'cardId': 'createCardMessage',
'card': { 'card': {
'header': { 'header': {
'title': 'qfixdpl', 'title': 'QFIXDPL',
'subtitle': 'Notification', 'subtitle': 'Notification',
'imageUrl': '%s', 'imageUrl': '%s',
'imageType': 'CIRCLE' 'imageType': 'CIRCLE'

17
src/client/store/db.sql Normal file
View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS qfixdpl_messages (
id UUID PRIMARY KEY,
sender_comp_id TEXT NOT NULL,
msg_seq_num BIGINT NOT NULL,
j_message JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON qfixdpl_messages(created_at);
CREATE INDEX IF NOT EXISTS idx_messages_session ON qfixdpl_messages(sender_comp_id, msg_seq_num);
CREATE TABLE IF NOT EXISTS qfixdpl_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_req_id TEXT NOT NULL UNIQUE,
raw_msg TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@ -2,7 +2,9 @@
package store package store
import ( import (
_ "embed"
"log/slog" "log/slog"
"strings"
"time" "time"
"quantex.com.ar/multidb" "quantex.com.ar/multidb"
@ -11,6 +13,9 @@ import (
"quantex.com/qfixdpl/src/common/tracerr" "quantex.com/qfixdpl/src/common/tracerr"
) )
//go:embed db.sql
var schemaSQL string
const dbPingSeconds = 30 const dbPingSeconds = 30
type Store struct { type Store struct {
@ -45,9 +50,31 @@ func New(config Config) (*Store, error) {
go s.db.PeriodicDBPing(time.Second * dbPingSeconds) go s.db.PeriodicDBPing(time.Second * dbPingSeconds)
if err := s.ensureTables(); err != nil {
return nil, tracerr.Errorf("error ensuring tables: %w", err)
}
return s, nil return s, nil
} }
func (p *Store) ensureTables() error {
statements := strings.Split(schemaSQL, ";")
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := p.db.Exec(stmt); err != nil {
return tracerr.Errorf("error executing schema statement: %w", err)
}
}
slog.Info("database tables ensured")
return nil
}
func (p *Store) CloseDB() { func (p *Store) CloseDB() {
p.db.Close() p.db.Close()
slog.Info("closing database connection.") slog.Info("closing database connection.")

View File

@ -0,0 +1,126 @@
package store
import (
"encoding/json"
"strconv"
"strings"
"time"
"quantex.com/qfixdpl/src/common/tracerr"
"quantex.com/qfixdpl/src/domain"
)
func (p *Store) SaveMessage(msg domain.Message) error {
jsonBytes, err := json.Marshal(msg.JMessage)
if err != nil {
return tracerr.Errorf("error marshaling j_message: %w", err)
}
_, err = p.db.Exec(
`INSERT INTO qfixdpl_messages (id, sender_comp_id, msg_seq_num, j_message, created_at)
VALUES ($1, $2, $3, $4, $5)`,
msg.ID,
msg.SenderCompID,
strconv.Itoa(msg.MsgSeqNum),
string(jsonBytes),
msg.CreatedAt.UTC().Format(time.RFC3339Nano),
)
if err != nil {
return tracerr.Errorf("error inserting message: %w", err)
}
return nil
}
func (p *Store) SaveLog(entry domain.LogEntry) error {
upsertStmt := `INSERT INTO qfixdpl_logs (quote_req_id, raw_msg)
VALUES ($1, $2)
ON CONFLICT (quote_req_id) DO UPDATE
SET raw_msg = qfixdpl_logs.raw_msg || E'\n' || EXCLUDED.raw_msg,
updated_at = NOW()`
_, err := p.db.Exec(upsertStmt, entry.QuoteReqID, entry.RawMsg)
if err != nil {
return tracerr.Errorf("error upserting log: %w", err)
}
return nil
}
func (p *Store) GetTodayMessages() ([]domain.Message, error) {
rows, err := p.db.Query(
`SELECT id, sender_comp_id, msg_seq_num, j_message, created_at
FROM qfixdpl_messages
WHERE created_at >= current_date
ORDER BY created_at ASC`,
)
if err != nil {
return nil, tracerr.Errorf("error querying today messages: %w", err)
}
defer rows.Close()
var messages []domain.Message
for rows.Next() {
var (
id, senderCompID string
msgSeqNum int
jMessageRaw []byte
createdAt time.Time
)
if err := rows.Scan(&id, &senderCompID, &msgSeqNum, &jMessageRaw, &createdAt); err != nil {
return nil, tracerr.Errorf("error scanning message row: %w", err)
}
var jMessage domain.FixMessageJSON
if err := json.Unmarshal(jMessageRaw, &jMessage); err != nil {
return nil, tracerr.Errorf("error unmarshaling j_message: %w", err)
}
sendingTime, _ := jMessage.Header["SendingTime"].(time.Time)
if sendingTime.IsZero() {
if s, ok := jMessage.Header["SendingTime"].(string); ok {
if t, parseErr := time.Parse(time.RFC3339Nano, s); parseErr == nil {
sendingTime = t
}
}
}
messages = append(messages, domain.Message{
ID: id,
SenderCompID: senderCompID,
MsgSeqNum: msgSeqNum,
SendingTime: sendingTime,
CreatedAt: createdAt,
JMessage: jMessage,
})
}
if err := rows.Err(); err != nil {
return nil, tracerr.Errorf("error iterating message rows: %w", err)
}
return messages, nil
}
func (p *Store) GetLogsByQuoteReqID(quoteReqID string) (domain.Logs, error) {
selectStmt := "SELECT raw_msg FROM qfixdpl_logs WHERE quote_req_id = '" + quoteReqID + "';"
response, err := p.db.Query(selectStmt)
if err != nil {
return domain.Logs{}, tracerr.Errorf("error querying logs: %w", err)
}
defer response.Close()
if !response.Next() {
return domain.Logs{}, nil
}
var rawMsg string
if err := response.Scan(&rawMsg); err != nil {
return domain.Logs{}, tracerr.Errorf("error scanning log row: %w", err)
}
return domain.Logs{Entries: strings.Split(rawMsg, "\n")}, nil
}

View File

@ -8,6 +8,7 @@ import (
"quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/client/api/rest" "quantex.com/qfixdpl/src/client/api/rest"
"quantex.com/qfixdpl/src/client/data" "quantex.com/qfixdpl/src/client/data"
"quantex.com/qfixdpl/src/client/fix"
googlechat "quantex.com/qfixdpl/src/client/notify/google" googlechat "quantex.com/qfixdpl/src/client/notify/google"
"quantex.com/qfixdpl/src/client/store" "quantex.com/qfixdpl/src/client/store"
"quantex.com/qfixdpl/src/client/store/external" "quantex.com/qfixdpl/src/client/store/external"
@ -38,15 +39,22 @@ func Runner(cfg app.Config) error {
userData := data.New() userData := data.New()
fixManager := fix.NewManager(cfg.FIX, appStore, notify)
if err = fixManager.Start(); err != nil {
return fmt.Errorf("error starting FIX acceptor: %w", err)
}
defer fixManager.Stop()
apiConfig := rest.Config{ apiConfig := rest.Config{
Port: cfg.APIBasePort, Port: cfg.APIBasePort,
AllowedOrigins: cfg.AllowedOrigins, AllowedOrigins: cfg.AllowedOrigins,
External: cfg.External, External: cfg.External,
AuthorizedServices: cfg.AuthorizedServices, AuthorizedServices: cfg.AuthorizedServices,
EnableJWTAuth: cfg.EnableJWTAuth, EnableJWTAuth: cfg.EnableJWTAuth,
ServiceAPIKey: cfg.ServiceAPIKey,
} }
api := rest.New(userData, appStore, apiConfig, notify) api := rest.New(userData, appStore, fixManager, apiConfig, notify)
api.Run() api.Run()
cmd.WaitForInterruptSignal(nil) cmd.WaitForInterruptSignal(nil)

50
src/domain/persistence.go Normal file
View File

@ -0,0 +1,50 @@
// Package domain defines all the domain models
package domain
import "time"
// ListTrade es la representacion exportada de un trade de List Trading.
type ListTrade struct {
QuoteRequest FixMessageJSON `json:"quote_request"`
}
// FixMessageJSON es la representacion estructurada de un mensaje FIX para almacenamiento.
type FixMessageJSON struct {
Direction string `json:"direction"`
MsgType string `json:"msg_type"`
QuoteReqID string `json:"quote_req_id"`
Header map[string]any `json:"header"`
Body map[string]any `json:"body"`
Trailer map[string]any `json:"trailer"`
ReceiveTime time.Time `json:"receive_time"`
}
// Message es una fila de qfixdpl_messages, con la metadata del header FIX hoisted
// para que los consumidores puedan ordenar/filtrar sin parsear el JSON.
type Message struct {
ID string `json:"id"`
SenderCompID string `json:"sender_comp_id"`
MsgSeqNum int `json:"msg_seq_num"`
SendingTime time.Time `json:"sending_time"`
CreatedAt time.Time `json:"created_at"`
JMessage FixMessageJSON `json:"j_message"`
}
// LogEntry es el DTO para insertar/actualizar un log crudo en qfixdpl_logs.
type LogEntry struct {
QuoteReqID string
RawMsg string
}
// Logs es la respuesta del endpoint GET /trades/:quoteReqID/logs.
type Logs struct {
Entries []string `json:"entries"`
}
// PersistenceStore define la interfaz de persistencia.
type PersistenceStore interface {
SaveMessage(msg Message) error
SaveLog(entry LogEntry) error
GetTodayMessages() ([]Message, error)
GetLogsByQuoteReqID(quoteReqID string) (Logs, error)
}

View File

@ -42,7 +42,7 @@ if COMMIT_MSG=$(QUANTEX_ENVIRONMENT=$ENV "${OUT_PATH}/qfixdpl" -v 2>/dev/null);
echo "$COMMIT_MSG" echo "$COMMIT_MSG"
else else
echo "---------------------------------" echo "---------------------------------"
echo "Skeleton" echo "QFIXDPL"
echo "Built at: ${BUILT_TIME}" echo "Built at: ${BUILT_TIME}"
echo "Branch: ${BUILD_BRANCH}" echo "Branch: ${BUILD_BRANCH}"
echo "SHA: ${BUILD_HASH}" echo "SHA: ${BUILD_HASH}"

View File

@ -3,7 +3,7 @@
set -e set -e
read -r -p "Issuer: " ISSUER read -r -p "Issuer: " ISSUER
read -r -p "Service (e.g. SKELETON): " SERVICE read -r -p "Service (e.g. QFIXDPL): " SERVICE
read -r -p "Token: " TOKEN read -r -p "Token: " TOKEN
read -r -p "Expire (e.g. 24h) [none]: " EXPIRY read -r -p "Expire (e.g. 24h) [none]: " EXPIRY

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
systemctl daemon-reload systemctl daemon-reload
systemctl restart skeleton.service systemctl restart qfixdpl.service