1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::fix::actions::{action, check_answer, logout, parameter, send};
8use anyhow::{bail, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct FixClientProtocol;
14impl FixClientProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20fn disconnect() -> ActionDefinition {
21 action(
22 "disconnect",
23 "Log out and close the connection without waiting for the counterparty's Logout",
24 vec![],
25 json!({"type": "disconnect"}),
26 )
27}
28fn actions() -> Vec<ActionDefinition> {
29 vec![send(), logout(), disconnect()]
30}
31
32pub static LOGGED_ON_EVENT: LazyLock<EventType> = LazyLock::new(|| {
33 EventType::new(
34 "fix_logged_on",
35 "The acceptor answered the Logon; the session is up",
36 send().example.clone(),
37 )
38 .with_parameters(vec![
39 parameter(
40 "sender_comp_id",
41 "string",
42 "This initiator's SenderCompID",
43 true,
44 ),
45 parameter("target_comp_id", "string", "The acceptor's CompID", true),
46 parameter("heartbeat_secs", "number", "The agreed HeartBtInt", true),
47 ])
48 .with_actions(actions())
49});
50pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
51 EventType::new(
52 "fix_message",
53 "An in-sequence application message from the acceptor",
54 json!({"type": "fix_logout"}),
55 )
56 .with_parameters(vec![
57 parameter("msg_type", "string", "MsgType code, e.g. 8", true),
58 parameter(
59 "msg_type_name",
60 "string",
61 "MsgType name, e.g. ExecutionReport",
62 true,
63 ),
64 parameter(
65 "seq",
66 "number",
67 "The message's MsgSeqNum (34) in this session, which a BusinessMessageReject refers to",
68 true,
69 ),
70 parameter(
71 "fields",
72 "array",
73 "Body fields in wire order: [{tag, name, value}]",
74 true,
75 ),
76 ])
77 .with_actions(actions())
78});
79pub static LOGGED_OUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
80 EventType::new(
81 "fix_logged_out",
82 "The session ended",
83 json!({"type": "disconnect"}),
84 )
85 .with_parameters(vec![parameter(
86 "reason",
87 "string",
88 "Why: the counterparty's Logout text, a sequence problem or a heartbeat timeout",
89 true,
90 )])
91 .with_actions(vec![disconnect()])
92});
93
94impl Protocol for FixClientProtocol {
95 fn protocol_name(&self) -> &'static str {
96 "FIX"
97 }
98 fn stack_name(&self) -> &'static str {
99 "ETH>IP>TCP>FIX"
100 }
101 fn keywords(&self) -> Vec<&'static str> {
102 vec![
103 "fix",
104 "fix protocol",
105 "initiator",
106 "order entry",
107 "new order single",
108 ]
109 }
110 fn description(&self) -> &'static str {
111 "FIX 4.x initiator: logs on, keeps the session (sequence numbers, resend and gap fill, heartbeats) and sends application messages"
112 }
113 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
114 actions()
115 }
116 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
117 vec![]
118 }
119 fn get_event_types(&self) -> Vec<EventType> {
120 vec![
121 LOGGED_ON_EVENT.clone(),
122 MESSAGE_EVENT.clone(),
123 LOGGED_OUT_EVENT.clone(),
124 ]
125 }
126 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
127 let p =
128 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
129 ParameterDefinition {
130 name: name.into(),
131 type_hint: kind.into(),
132 description: description.into(),
133 required: false,
134 example,
135 default,
136 }
137 };
138 vec![
139 p(
140 "sender_comp_id",
141 "string",
142 "This initiator's SenderCompID",
143 json!("BUYSIDE"),
144 Some(json!(crate::server::fix::DEFAULT_COMP_ID)),
145 ),
146 p(
147 "target_comp_id",
148 "string",
149 "The acceptor's CompID",
150 json!("EXCHANGE"),
151 Some(json!(super::DEFAULT_TARGET)),
152 ),
153 p(
154 "begin_string",
155 "string",
156 "FIX.4.0 to FIX.4.4",
157 json!("FIX.4.2"),
158 Some(json!(crate::server::fix::DEFAULT_BEGIN_STRING)),
159 ),
160 p(
161 "heartbeat_secs",
162 "integer",
163 "HeartBtInt to propose (0 to 3600)",
164 json!(10),
165 Some(json!(super::DEFAULT_HEARTBEAT.as_secs())),
166 ),
167 p(
168 "reset_seq_num",
169 "boolean",
170 "Ask the acceptor to reset sequence numbers (141=Y)",
171 json!(true),
172 Some(json!(true)),
173 ),
174 p(
175 "username",
176 "string",
177 "Username (553) for the Logon",
178 json!("trader"),
179 None,
180 ),
181 p(
182 "password",
183 "string",
184 "Password (554) for the Logon",
185 json!("secret"),
186 None,
187 ),
188 ]
189 }
190 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
191 use crate::protocol::metadata::*;
192 ProtocolMetadataV2::builder()
193 .state(DevelopmentState::Experimental)
194 .privilege_requirement(PrivilegeRequirement::None)
195 .implementation("The FIX codec and session layer the acceptor uses, as initiator")
196 .llm_control("Which application messages to send and how to react to the acceptor's")
197 .e2e_testing("tests/client/fix: QuickFIX/Go 0.9.12 (independent, validating against FIX44.xml) as acceptor answers a NewOrderSingle with an ExecutionReport and an OrderCancelRequest with a BusinessMessageReject; logout")
198 .notes("One session per client, in memory. No FIXT.1.1/FIX 5.0 and no reconnect.")
199 .max_inbound_bytes(crate::server::fix::codec::MAX_BODY)
200 .build()
201 }
202 fn example_prompt(&self) -> &'static str {
203 "Log on to the FIX acceptor at 127.0.0.1:9876 as BUYSIDE and buy 100 AAPL at 150"
204 }
205 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
206 let llm = json!({"type":"open_client","protocol":"fix","remote_addr":"127.0.0.1:9876","instruction":"Buy 100 AAPL at 150","startup_params":{"sender_comp_id":"BUYSIDE","target_comp_id":"EXCHANGE"}});
207 let mut static_example = llm.clone();
208 static_example["event_handlers"] = json!([
209 {"event_pattern":"fix_logged_on","handler":{"type":"static","actions":[{"type":"fix_send","msg_type":"NewOrderSingle","fields":[{"name":"ClOrdID","value":"1"},{"name":"HandlInst","value":"1"},{"name":"Symbol","value":"AAPL"},{"name":"Side","value":"1"},{"name":"TransactTime","value":"20261001-12:00:00.000"},{"name":"OrderQty","value":"100"},{"name":"OrdType","value":"2"},{"name":"Price","value":"150"}]}]}},
210 {"event_pattern":"fix_message","handler":{"type":"static","actions":[]}}
211 ]);
212 let mut scripted = static_example.clone();
213 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'fix_logout','text':'done'}] if e['msg_type']=='8' else []}))"});
214 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
215 }
216 fn group_name(&self) -> &'static str {
217 "Application"
218 }
219}
220
221impl Client for FixClientProtocol {
222 fn connect(
223 &self,
224 ctx: ConnectContext,
225 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
226 {
227 Box::pin(super::connect(ctx))
228 }
229 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
230 match v["type"].as_str() {
231 Some("disconnect") => return Ok(ClientActionResult::Disconnect),
232 Some("fix_send" | "fix_logout") => check_answer(&v)?,
233 _ => bail!("Unknown FIX client action"),
234 }
235 Ok(ClientActionResult::Custom {
236 name: v["type"].as_str().unwrap_or_default().into(),
237 data: v,
238 })
239 }
240}