Connectors
A connector connects ATAS to a source of market data (a built-in connector, also to a broker). The built-in connectors (Rithmic, CQG, dxFeed, the crypto exchanges and others) are written with the same SDK that is available to you: you can write a connector to your own data feed, a vendor API, a recorded data set, a simulator, and use it in ATAS like any built-in connection. Charts, the SmartDOM, the heatmap, indicators and strategies receive its data exactly as from any other connection.
This article describes a market data connector. Trading through third-party connectors is not supported: the base class also carries the trading API (portfolios, positions, orders) of the built-in connectors, but a connector of your own must be a market data feed only. Connectors work the same way in the classic ATAS and in ATAS X (see ATAS X and macOS).
What you build
A class library for net10.0 (not net10.0-windows) that references ATAS.DataFeedsCore and Utils.Common from
the platform folder (depending on the members you use, also OFT.Attributes and OFT.Localization). The connector SDK is the ATAS.DataFeedsCore namespace; the settings base class is in
OFT.Core. The library contains two public classes:
| Class | Base class | Role |
|---|---|---|
| connection settings | BaseConnectorSettings<TConnector, TSelf> |
the connector's name in the «Add connection» dialog, the properties the user edits, saved with the connection |
| connector | BaseConnector<TMessage, TPortfolioKey, TSecurityKey> | connects, finds instruments, publishes trades, best prices and the order book |
The platform finds the settings class (a public non-abstract class with a public parameterless constructor), creates the connector through it and drives the connector through its base class.
The settings class
| Member | Mandatory | Meaning |
|---|---|---|
[DisplayName] |
yes | the connector name in the dialog. It must be unique, see Name rules |
[Description], [Category] |
no | the text in the dialog and the dialog group. The category does not affect the connection limits of the tariff |
Features |
yes | ConnectorFeatures.MarketData. Trading through third-party connectors is not supported, so do not add Executions, StopOrders or OcoOrders |
OnApplySettings(connector) |
yes | copies the settings to the connector. Called after the connector is created and after every change of the settings |
CompareCredentials(other) |
yes | true if both connections use the same account: the platform does not connect two such connections at once. Return false when there are no credentials |
| public properties | no | shown in the connection settings (use [Display], [Range], [Required]) and saved with the connection. Use SecureString for passwords |
CheckSupported(out message) |
no | return false with a message if the connector cannot run on this machine (for example, a Windows-only vendor library on macOS) |
Login and password connectors implement ILoginPasswordConnectorSettings; the platform then shows the standard login
fields.
/// <summary>
/// The connection settings: the connector's name in the "add connection" dialog and the properties the user edits.
/// </summary>
[DisplayName("Random walk feed")]
[Description("Simulated market data: a random walk of trades and a synthetic order book.")]
[Category("Custom")]
public sealed class RandomWalkConnectorSettings : BaseConnectorSettings<RandomWalkConnector, RandomWalkConnectorSettings>
{
[Display(Name = "Trades per second", GroupName = "Simulation", Order = 10)]
[Range(1, 50)]
public int TradesPerSecond { get; set; } = 5;
[Display(Name = "Order book levels per side", GroupName = "Simulation", Order = 20)]
[Range(1, 50)]
public int BookLevels { get; set; } = 10;
// Market data only: the platform offers no trading through this connection.
public override ConnectorFeatures Features => ConnectorFeatures.MarketData;
// Copies the settings into the connector; called after creation and after every change.
protected override void OnApplySettings(RandomWalkConnector connector)
{
connector.TradesPerSecond = TradesPerSecond;
connector.BookLevels = BookLevels;
}
// No credentials: two connections of this type never use the same account.
protected override bool CompareCredentials(RandomWalkConnectorSettings other) => false;
}
The settings are saved as JSON with the type name of your class. Keep the saved form compatible between versions of your DLL: do not rename the class or its namespace, and do not change the type of a saved property. A saved connection that cannot be restored (the DLL is missing, the constructor or a property setter throws, a saved value no longer fits the property) is not deleted: it is kept unchanged in the connections list, marked as unavailable and is never connected, so restoring the DLL brings it back.
The connector class
The type parameters of BaseConnector<TMessage, TPortfolioKey, TSecurityKey> are your queue message type (see
Threading) and the keys you use for accounts and instruments (usually string).
| Member | Mandatory | Meaning |
|---|---|---|
IsSupportedAmericanFutures, IsSupportedAmericanStocks, IsSupportedRussianMarket, IsSupportedCrypto, IsSupportedCfd |
yes (the first three are abstract) | the instrument kinds your feed serves: the platform asks your connector only for these |
IsSupportedTradingFunctions, IsSupportedStopOrders, IsSupportedServerOCO |
yes | false for a data feed |
OnConnect() |
yes | opens the connection; call OnConnected() when it is established (by default within 60 seconds, the ConnectTimeout property; otherwise the attempt is aborted). An exception fails the attempt: the connection shows the error and stays disconnected; the platform does not retry a first connect by itself |
OnDisconnect() |
yes | closes the connection; call OnDisconnected() |
OnSearchSecurities(filter) |
yes | finds the instrument the user opened, see Instruments |
OnSubscribeMarketData(security, types), OnUnsubscribeMarketData(security, types) |
yes | start and stop the data of an instrument. The base class counts subscribers: you are called once per change of the combined subscription |
OnProcess(message) |
yes | handles the messages you put into the connector queue |
OnSearchSecurities(securityKey), OnRegisterOrder, OnCancelOrder |
yes (abstract) | may be empty or report an error in a data feed |
OnSendHeartbeat(), HeartbeatTimeout |
no | a periodic check of a live connection |
// Called on a thread-pool thread. Open the transport here, then report success.
protected override void OnConnect()
{
var period = TimeSpan.FromMilliseconds(1000.0 / Math.Max(1, TradesPerSecond));
_timer = new Timer(_ => Enqueue(Step), null, period, period);
OnConnected();
}
protected override void OnDisconnect()
{
_timer?.Dispose();
_timer = null;
OnDisconnected();
}
You publish data with the protected methods of the base class:
| Method | Data |
|---|---|
ProcessSecurity(key, securityId, message, update) |
creates or updates the Security of an instrument |
ProcessTick(security, id, time, direction, price, volume) |
a trade |
ProcessBestBidAsk(security, time, MarketDataType.Bid/Ask, price, volume) |
the best bid or ask |
ProcessMarketDepths(security, levels) |
changed order book levels (MarketDepth: side, price, volume; volume 0 removes the level) |
ProcessSummary(key, update) |
the daily summary (open, high, low, volume) |
// One simulation step for every security, on the connector queue thread.
private void Step()
{
var time = DateTime.UtcNow;
foreach (var simulation in _simulations.Values)
{
var security = simulation.Security;
var tick = security.TickSize;
var move = _random.Next(-2, 3);
simulation.Price = Math.Max(tick, simulation.Price + move * tick);
var direction = move >= 0 ? TradeDirection.Buy : TradeDirection.Sell;
ProcessTick(security, ++_tradeId, time, direction, simulation.Price, _random.Next(1, 20));
ProcessBestBidAsk(security, time, MarketDataType.Bid, simulation.Price - tick, _random.Next(1, 100));
ProcessBestBidAsk(security, time, MarketDataType.Ask, simulation.Price + tick, _random.Next(1, 100));
ProcessMarketDepths(security, BuildBook(simulation, time));
}
}
The Process… methods drop data of instruments that nobody subscribed to, so you do not need to track the
subscriptions yourself. Times are UTC.
Connection loss. When your source drops a connection that was established, call OnConnectionError(exception)
with the reason and then OnDisconnected(). The platform then reconnects by itself: it calls OnConnect again and,
after OnConnected, searches the instruments that are still open again (OnSearchSecurities) and subscribes them
again (OnSubscribeMarketData). Report a refusal that a retry cannot fix (for example, rejected credentials) as a
ConnectionRefusedException (namespace ATAS.DataFeedsCore.Exceptions): the platform then stops reconnecting
and shows the reason. The same two calls during OnConnect, before OnConnected, fail the connect attempt like an
exception.
Threading
The calls of all the extension kinds side by side: Call model.
OnConnectandOnDisconnectrun on a thread-pool thread.OnSearchSecuritiesand the subscription methods run on the caller's thread, which may be the UI thread: do not block in them.- Every connector has its own queue thread.
Enqueue(message)hands a message toOnProcesson that thread,Enqueue(action)runs an action there. CallProcessSecurity,ProcessTick,ProcessBestBidAskandProcessMarketDepthsfrom the queue thread: the state of an instrument is not synchronized otherwise. The usual pattern is that the network callbacks of your API only enqueue, and all processing happens in the queue. - An exception in
OnProcessor in an enqueued action is logged and the queue continues. - AsyncConnector<TPortfolioKey, TSecurityKey> is a variant of the base class for
asynccode:await SwitchToConnectorThreadAsync()continues on the queue thread.
Instruments
The user opens instruments from the ATAS instrument catalogue. When a chart or a SmartDOM opens one, the platform
asks every connected connector that supports the instrument kind with a SecurityFilter: Code (the contract
code, for example ESZ6), Exchange, Type, ContractCode (the root, ES) and Expiration. If several
connections serve the instrument, the one higher in the connections list wins.
Answer with exactly one security through RaiseSearchSecuritiesResult(filter, null, [security]), or with an
error (RaiseSearchSecuritiesResult(filter, exception, [])) when you do not serve the instrument. Before that,
create the security with ProcessSecurity and fill at least Code, Exchange, Type and TickSize; securityId
is your unique id in the form code@exchange.
// The platform asks every connected connector for the instrument the user opened. Answer with exactly one
// security, or with an error.
protected override void OnSearchSecurities(SecurityFilter filter)
{
Enqueue(() =>
{
if (!_contracts.TryGetValue(filter.ContractCode ?? string.Empty, out var contract))
{
RaiseSearchSecuritiesResult(filter, new InvalidOperationException($"{filter.Code} is not simulated."), []);
return;
}
var securityId = $"{filter.Code}@{filter.Exchange}";
Security? found = null;
ProcessSecurity(securityId, securityId, filter, (f, isNew, security) =>
{
security.Code = f.Code;
security.Exchange = f.Exchange;
security.Instrument = f.ContractCode;
security.Type = f.Type;
security.TickSize = contract.TickSize;
found = security;
});
if (!_simulations.ContainsKey(securityId))
_simulations[securityId] = new Simulation { Security = found!, Price = contract.StartPrice };
RaiseSearchSecuritiesResult(filter, null, [found!]);
});
}
A connector cannot add instruments that are not in the ATAS catalogue.
History
The connector API covers live data only. Historical bars are not part of it: a connector cannot supply, replace or extend the history of an instrument, and which bars a chart shows before the live part does not depend on the connection that delivers the live data. Make sure the prices of your live data match the catalogue instrument, otherwise the live part will not continue the bars the chart already shows.
Example
The complete simulated data feed: for a few CME futures it answers the instrument search and produces a random walk of trades, best prices and a synthetic order book. It does not trade.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using ATAS.DataFeedsCore;
using OFT.Core;
namespace ATAS.Docs.Samples.Connectors;
/// <summary>
/// The connection settings: the connector's name in the "add connection" dialog and the properties the user edits.
/// </summary>
[DisplayName("Random walk feed")]
[Description("Simulated market data: a random walk of trades and a synthetic order book.")]
[Category("Custom")]
public sealed class RandomWalkConnectorSettings : BaseConnectorSettings<RandomWalkConnector, RandomWalkConnectorSettings>
{
[Display(Name = "Trades per second", GroupName = "Simulation", Order = 10)]
[Range(1, 50)]
public int TradesPerSecond { get; set; } = 5;
[Display(Name = "Order book levels per side", GroupName = "Simulation", Order = 20)]
[Range(1, 50)]
public int BookLevels { get; set; } = 10;
// Market data only: the platform offers no trading through this connection.
public override ConnectorFeatures Features => ConnectorFeatures.MarketData;
// Copies the settings into the connector; called after creation and after every change.
protected override void OnApplySettings(RandomWalkConnector connector)
{
connector.TradesPerSecond = TradesPerSecond;
connector.BookLevels = BookLevels;
}
// No credentials: two connections of this type never use the same account.
protected override bool CompareCredentials(RandomWalkConnectorSettings other) => false;
}
/// <summary>
/// Simulates a few CME futures. Every state change happens on the connector queue thread (see Enqueue).
/// </summary>
public sealed class RandomWalkConnector : BaseConnector<object, string, string>
{
private sealed class Simulation
{
public required Security Security { get; init; }
public decimal Price { get; set; }
public Dictionary<decimal, MarketDataType> Book { get; } = new();
}
// Contract root -> tick size and start price.
private static readonly Dictionary<string, (decimal TickSize, decimal StartPrice)> _contracts = new()
{
["ES"] = (0.25m, 5000m),
["NQ"] = (0.25m, 18000m),
["CL"] = (0.01m, 70m),
};
private readonly Dictionary<string, Simulation> _simulations = new();
private readonly Random _random = new();
private Timer? _timer;
private long _tradeId;
public int TradesPerSecond { get; set; } = 5;
public int BookLevels { get; set; } = 10;
// Instrument kinds this connector serves: the platform asks it only for these.
public override bool IsSupportedAmericanFutures => true;
public override bool IsSupportedAmericanStocks => false;
public override bool IsSupportedRussianMarket => false;
public override bool IsSupportedTradingFunctions => false;
public override bool IsSupportedStopOrders => false;
public override bool IsSupportedServerOCO => false;
// Called on a thread-pool thread. Open the transport here, then report success.
protected override void OnConnect()
{
var period = TimeSpan.FromMilliseconds(1000.0 / Math.Max(1, TradesPerSecond));
_timer = new Timer(_ => Enqueue(Step), null, period, period);
OnConnected();
}
protected override void OnDisconnect()
{
_timer?.Dispose();
_timer = null;
OnDisconnected();
}
// Messages passed to Enqueue(TMessage); this connector enqueues only actions.
protected override void OnProcess(object message)
{
}
// The platform asks every connected connector for the instrument the user opened. Answer with exactly one
// security, or with an error.
protected override void OnSearchSecurities(SecurityFilter filter)
{
Enqueue(() =>
{
if (!_contracts.TryGetValue(filter.ContractCode ?? string.Empty, out var contract))
{
RaiseSearchSecuritiesResult(filter, new InvalidOperationException($"{filter.Code} is not simulated."), []);
return;
}
var securityId = $"{filter.Code}@{filter.Exchange}";
Security? found = null;
ProcessSecurity(securityId, securityId, filter, (f, isNew, security) =>
{
security.Code = f.Code;
security.Exchange = f.Exchange;
security.Instrument = f.ContractCode;
security.Type = f.Type;
security.TickSize = contract.TickSize;
found = security;
});
if (!_simulations.ContainsKey(securityId))
_simulations[securityId] = new Simulation { Security = found!, Price = contract.StartPrice };
RaiseSearchSecuritiesResult(filter, null, [found!]);
});
}
// Searches by the connector's own security key; not needed by this connector.
protected override void OnSearchSecurities(string securityId)
{
}
// The base class counts the subscriptions and drops data nobody subscribed to, so the simulation just runs for
// every known security. A real connector sends its subscribe request here.
protected override void OnSubscribeMarketData(Security security, SubscriptionType subscriptionTypes)
{
}
protected override void OnUnsubscribeMarketData(Security security, SubscriptionType subscriptionTypes)
{
}
// Trading is not supported (Features has no Executions); report an error if an order arrives anyway.
protected override void OnRegisterOrder(Order order)
=> ProcessOrderError(order, "Random walk feed does not support trading.");
protected override void OnCancelOrder(Order order, long extId)
=> ProcessOrderError(order, "Random walk feed does not support trading.");
// One simulation step for every security, on the connector queue thread.
private void Step()
{
var time = DateTime.UtcNow;
foreach (var simulation in _simulations.Values)
{
var security = simulation.Security;
var tick = security.TickSize;
var move = _random.Next(-2, 3);
simulation.Price = Math.Max(tick, simulation.Price + move * tick);
var direction = move >= 0 ? TradeDirection.Buy : TradeDirection.Sell;
ProcessTick(security, ++_tradeId, time, direction, simulation.Price, _random.Next(1, 20));
ProcessBestBidAsk(security, time, MarketDataType.Bid, simulation.Price - tick, _random.Next(1, 100));
ProcessBestBidAsk(security, time, MarketDataType.Ask, simulation.Price + tick, _random.Next(1, 100));
ProcessMarketDepths(security, BuildBook(simulation, time));
}
}
// The changed order book levels: zero volume for every level that left the book (or changed its side), then the
// new levels.
private List<MarketDepth> BuildBook(Simulation simulation, DateTime time)
{
var security = simulation.Security;
var tick = security.TickSize;
var book = new Dictionary<decimal, MarketDataType>();
for (var i = 1; i <= BookLevels; i++)
{
book[simulation.Price - i * tick] = MarketDataType.Bid;
book[simulation.Price + i * tick] = MarketDataType.Ask;
}
var levels = simulation.Book
.Where(old => !book.TryGetValue(old.Key, out var side) || side != old.Value)
.Select(old => Level(security, old.Value, old.Key, 0, time))
.ToList();
levels.AddRange(book.Select(level => Level(security, level.Value, level.Key, _random.Next(1, 200), time)));
simulation.Book.Clear();
foreach (var level in book)
simulation.Book[level.Key] = level.Value;
return levels;
}
private static MarketDepth Level(Security security, MarketDataType side, decimal price, decimal volume, DateTime time)
=> new() { Security = security, Type = side, Price = price, Volume = volume, Time = time };
}
Installing the connector
Put the DLL into the custom connectors folder of the platform settings: %AppData%\ATAS\CustomConnectors for
the classic ATAS, %AppData%\ATAS X\CustomConnectors for ATAS X, ~/Library/Application Support/ATAS/CustomConnectors
on macOS (create the folder if it does not exist). It is not the Connectors folder next to it: that one holds the
data of the connections.
- Every DLL of the folder is loaded, whatever its name. Put the libraries your connector depends on next to it; they
are found by their assembly name. A library the platform already has (for example
Newtonsoft.Json) is not loaded again: the platform's copy is used. Native libraries of a vendor API can be placed in the same folder. - The folder is read at the start of the platform. After you replace the DLL, restart the platform; the DLL is locked while the platform runs.
- The connector appears in the «Add connection» dialog. The platform never creates or connects a custom connection on its own: the user adds it, and «connect automatically» works as for any connection.
- To debug the connector, attach the debugger to the running platform
(Diagnostics); keep the
.pdbnext to the DLL. - Problems found while loading (an unreadable DLL, a name conflict, a missing parameterless constructor) are written to the platform log, and the connector is not listed.
- An exception thrown by your settings class while a saved connection is restored, by the connector constructor or by
OnApplySettingsis written to the log and shown on that connection; the platform and the other connections keep working. Changed settings whoseOnApplySettingsthrows are not saved.
Keep the start fast
Your code runs inside the platform process, and part of it runs while the platform starts: the DLL is loaded
(including any module initializer), saved connections are restored (settings constructors and property setters), and
their connectors are created and configured (connector constructor, OnApplySettings,
IConnectorSettingsSupportInitialization.Initialize). The platform cannot interrupt code that hangs there, and a hang
there stops the platform start. So keep constructors, property setters and OnApplySettings fast and free of I/O,
never open a network connection or wait for anything there, and do the slow work in OnConnect (a thread-pool
thread, bounded by the connection timeout) or in the connector queue.
Name rules
The [DisplayName] of the settings class identifies the connector: the connections are saved with it, and the data
folder of the connector is named after it. A custom connector whose name equals the name of a built-in connector
(ignoring case), or whose settings class has the full name of a built-in one, is not loaded: built-in connectors
always win. Between two custom connectors with the same name, the one from the DLL that comes first in file name
order is loaded. Choose a name that includes your product or company name.
ATAS X and macOS
- Build for
net10.0, notnet10.0-windows, and do not use WPF or Windows Forms: the connection settings are edited in the standard property grid of both platforms, so a connector does not need them. ATAS X does not load a DLL that references WPF or Windows Forms (the reason is written to the log). - A vendor API that exists only for Windows: return
falsefromCheckSupportedon other systems, so the connection shows the reason instead of failing on connect. - Native libraries must be built for the system and processor of the platform (for example
arm64on Apple silicon Macs).
Tariff restriction
Custom connectors can be restricted by the tariff (Tariffs): your connector is still
listed in the «Add connection» dialog, but choosing it shows the tariff banner; connections added earlier stay in the
connections list, show «Custom connectors are not available on your current plan» and are not connected,
automatically or manually. The tariff is checked every time a connection is added or connected, so the connections
work again as soon as the restriction is lifted. The other connection limits of the tariff (adding connections, the
number of active connections) apply to custom connectors as well. A custom connection always counts as a crypto
connection if its connector reports IsSupportedCrypto, and as a classic (US and EU markets) connection otherwise,
whatever [Category] the settings class declares.