use std::env;
use std::process::ExitCode;
use futures_util::StreamExt;
use lightstreamer_rs::{
Client, ClientConfig, Credentials, FieldSchema, ItemGroup, ServerAddress, Subscription,
SubscriptionEvent, SubscriptionMode,
};
use serde::Deserialize;
const DEMO_GATEWAY: &str = "https://demo-api.ig.com/gateway/deal";
const LIVE_GATEWAY: &str = "https://api.ig.com/gateway/deal";
const DEMO_STREAM_HOST: &str = "https://demo-apd.marketdatasystems.com";
const LIVE_STREAM_HOST: &str = "https://apd.marketdatasystems.com";
const DEFAULT_EPICS: &str = "CS.D.GBPUSD.CFD.IP,IX.D.FTSE.CFD.IP";
const MARKET_FIELDS: [&str; 5] = [
"BID",
"OFFER",
"UPDATE_TIME",
"MARKET_STATE",
"MARKET_DELAY",
];
const ACCOUNT_FIELDS: [&str; 4] = ["PNL", "AVAILABLE_CASH", "MARGIN", "EQUITY"];
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IgSessionBody {
lightstreamer_endpoint: String,
current_account_id: String,
}
struct IgSession {
lightstreamer_endpoint: String,
current_account_id: String,
cst: String,
security_token: String,
}
impl std::fmt::Debug for IgSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IgSession")
.field("lightstreamer_endpoint", &self.lightstreamer_endpoint)
.field("current_account_id", &"<redacted>")
.field("cst", &"<redacted>")
.field("security_token", &"<redacted>")
.finish()
}
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("error: {error}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let session = match tokens_from_environment() {
Some(session) => session,
None => log_in().await?,
};
connect_and_stream(session).await
}
fn tokens_from_environment() -> Option<IgSession> {
let cst = env::var("IG_CST").ok()?;
let security_token = env::var("IG_XST").ok()?;
let current_account_id = env::var("IG_ACCOUNT_ID").ok()?;
let lightstreamer_endpoint = env::var("IG_LS_ENDPOINT").unwrap_or_else(|_| {
match env::var("IG_ENV").as_deref() {
Ok("live") => LIVE_STREAM_HOST,
_ => DEMO_STREAM_HOST,
}
.to_owned()
});
Some(IgSession {
lightstreamer_endpoint,
current_account_id,
cst,
security_token,
})
}
async fn log_in() -> Result<IgSession, Box<dyn std::error::Error>> {
let api_key = required("IG_API_KEY")?;
let identifier = required("IG_IDENTIFIER")?;
let password = required("IG_PASSWORD")?;
let gateway = match env::var("IG_ENV").as_deref() {
Ok("live") => LIVE_GATEWAY,
_ => DEMO_GATEWAY,
};
println!("logging in to {gateway} …");
let http = reqwest::Client::new();
let response = http
.post(format!("{gateway}/session"))
.header("X-IG-API-KEY", &api_key)
.header("Version", "2")
.json(&serde_json::json!({
"identifier": identifier,
"password": password,
}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("IG refused the login ({status}): {body}").into());
}
let cst = header(&response, "CST")?;
let security_token = header(&response, "X-SECURITY-TOKEN")?;
let body: IgSessionBody = response.json().await?;
Ok(IgSession {
lightstreamer_endpoint: body.lightstreamer_endpoint,
current_account_id: body.current_account_id,
cst,
security_token,
})
}
async fn connect_and_stream(session: IgSession) -> Result<(), Box<dyn std::error::Error>> {
println!("streaming endpoint: {}", session.lightstreamer_endpoint);
let ls_password = format!("CST-{}|XST-{}", session.cst, session.security_token);
let config = ClientConfig::builder(ServerAddress::try_new(&session.lightstreamer_endpoint)?)
.with_credentials(Credentials::new(&session.current_account_id, ls_password))
.build()?;
let (client, session_events) = Client::connect(config).await?;
drop(session_events);
println!("connected to Lightstreamer\n");
let epics = env::var("IG_EPICS").unwrap_or_else(|_| DEFAULT_EPICS.to_owned());
let market_items: Vec<String> = epics
.split(',')
.map(str::trim)
.filter(|epic| !epic.is_empty())
.map(|epic| format!("MARKET:{epic}"))
.collect();
println!("watching {} market(s): {epics}", market_items.len());
let markets = client
.subscribe(
Subscription::new(
SubscriptionMode::Merge,
ItemGroup::from_items(market_items)?,
FieldSchema::from_fields(MARKET_FIELDS)?,
)
.with_snapshot(lightstreamer_rs::Snapshot::On),
)
.await?;
let account = client
.subscribe(Subscription::new(
SubscriptionMode::Merge,
ItemGroup::from_items([format!("ACCOUNT:{}", session.current_account_id)])?,
FieldSchema::from_fields(ACCOUNT_FIELDS)?,
))
.await?;
let mut events = futures_util::stream::select(
markets.map(|event| ("market", event)),
account.map(|event| ("account", event)),
);
while let Some((source, event)) = events.next().await {
match event {
SubscriptionEvent::Activated {
item_count,
field_count,
..
} => println!("[{source}] subscribed: {item_count} items × {field_count} fields"),
SubscriptionEvent::Update(update) => {
let values: Vec<String> = update
.changed_fields()
.map(|field| format!("{}={}", field.name(), field.value().text_or("(null)")))
.collect();
println!(
"[{source}] {:<28} {}",
update.item_name(),
values.join(" ")
);
}
SubscriptionEvent::Rejected(error) => {
eprintln!("[{source}] IG refused the subscription: {error}");
}
SubscriptionEvent::Unsubscribed => println!("[{source}] unsubscribed"),
other => println!("[{source}] {other:?}"),
}
}
client.disconnect().await?;
Ok(())
}
fn required(name: &str) -> Result<String, String> {
env::var(name).map_err(|_| {
format!("{name} is not set — see the header of this example for what it needs")
})
}
fn header(response: &reqwest::Response, name: &str) -> Result<String, String> {
response
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
.ok_or_else(|| format!("IG's login response carried no {name} header"))
}