Add Linux-native cTrader cBot build/run scaffold

Build cBots in C# and compile them to .algo entirely on Linux via the
official cTrader.Automate NuGet package + 'dotnet build' — no Windows and
no cTrader desktop app. Run/backtest headless through Spotware's official
cTrader console Docker image.

Includes:
- src/SampleCBot: example SMA-crossover cBot (SDK-style net6.0 class library)
- scripts/install-dotnet.sh: root-free local .NET SDK install (~/.dotnet)
- scripts/build.sh: dotnet build -> dist/<ProjectName>.algo, with a workaround
  for cTrader.Automate naming the .algo after the project's parent folder
- scripts/run-console.sh: wrapper over ghcr.io/spotware/ctrader-console
- docs/research-findings.md: sourced research; .algo verified as a proprietary
  'algo'-magic container (not a ZIP); build verified end to end on Linux
This commit is contained in:
2026-06-18 20:54:56 +02:00
commit 9c39a743d0
10 changed files with 535 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
net6.0 is the runtime cTrader's algo host expects ("modern .NET 6 algos").
A newer SDK can still build for this target; the net6.0 reference pack is
restored from NuGet automatically.
-->
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!--
The cTrader.Automate MSBuild targets normally copy the produced .algo into
~/Documents/cAlgo/Sources/... — a Windows-only path. Turn that publish step
off; the .algo is still emitted into bin/<config>/<tfm>/.
-->
<AlgoPublish>false</AlgoPublish>
</PropertyGroup>
<!--
NOTE on the .algo file name: cTrader.Automate 1.0.17 names the produced .algo
after the PARENT directory of the project folder (see its cTrader.Automate.targets:
_AlgoFileName = GetFileName(GetDirectoryName(MSBuildProjectDirectory))). There is
no public property to override it, and two bots under the same folder would collide.
The bot's name shown inside cTrader comes from the class/metadata, not the file name,
so this is cosmetic — but to get clean, non-colliding artifacts, scripts/build.sh
renames each collected .algo to <ProjectName>.algo when copying into dist/.
-->
<ItemGroup>
<!--
The official Spotware SDK. Its MSBuild targets are what package the compiled
assembly into the encrypted .algo container during `dotnet build`.
Owner on nuget.org must be "spotware" (do not confuse with the third-party
"cAlgo.API" package). The "1.*-*" range matches the official samples and
includes pre-release SDK builds.
-->
<PackageReference Include="cTrader.Automate" Version="1.*-*" />
</ItemGroup>
</Project>
+81
View File
@@ -0,0 +1,81 @@
using cAlgo.API;
using cAlgo.API.Indicators;
namespace SampleCBot
{
// A minimal but complete example cBot: a simple moving-average crossover.
//
// It goes long when the fast SMA crosses above the slow SMA and short on the
// opposite cross, closing any opposing position first. It is intentionally
// simple — its job is to exercise the full Linux build/run toolchain end to
// end (compile to .algo with `dotnet build`, run headless in the cTrader
// console Docker image), NOT to be a profitable strategy.
[Robot(AccessRights = AccessRights.None)]
public class SmaCrossOverBot : Robot
{
[Parameter("Fast MA Period", DefaultValue = 12, MinValue = 1, Group = "Moving Averages")]
public int FastPeriod { get; set; }
[Parameter("Slow MA Period", DefaultValue = 26, MinValue = 1, Group = "Moving Averages")]
public int SlowPeriod { get; set; }
[Parameter("Volume (lots)", DefaultValue = 0.01, MinValue = 0.01, Step = 0.01, Group = "Trade")]
public double VolumeInLots { get; set; }
[Parameter("Stop Loss (pips)", DefaultValue = 20, MinValue = 0, Group = "Trade")]
public double StopLossPips { get; set; }
[Parameter("Take Profit (pips)", DefaultValue = 40, MinValue = 0, Group = "Trade")]
public double TakeProfitPips { get; set; }
private const string Label = "SmaCrossOverBot";
private SimpleMovingAverage _fastMa;
private SimpleMovingAverage _slowMa;
protected override void OnStart()
{
_fastMa = Indicators.SimpleMovingAverage(Bars.ClosePrices, FastPeriod);
_slowMa = Indicators.SimpleMovingAverage(Bars.ClosePrices, SlowPeriod);
}
protected override void OnBar()
{
// Compare the two most recently *closed* bars. Index 1 is the last
// closed bar; index 2 the one before it. (Index 0 is still forming.)
var fastPrev = _fastMa.Result.Last(2);
var slowPrev = _slowMa.Result.Last(2);
var fastNow = _fastMa.Result.Last(1);
var slowNow = _slowMa.Result.Last(1);
var crossedUp = fastPrev <= slowPrev && fastNow > slowNow;
var crossedDown = fastPrev >= slowPrev && fastNow < slowNow;
if (crossedUp)
EnterMarket(TradeType.Buy);
else if (crossedDown)
EnterMarket(TradeType.Sell);
}
private void EnterMarket(TradeType direction)
{
var ours = Positions.FindAll(Label, SymbolName);
// Close any of our positions pointing the other way; bail out if we
// already hold one in the desired direction.
foreach (var position in ours)
{
if (position.TradeType != direction)
ClosePosition(position);
else
return;
}
var volume = Symbol.QuantityToVolumeInUnits(VolumeInLots);
double? stopLoss = StopLossPips > 0 ? StopLossPips : (double?)null;
double? takeProfit = TakeProfitPips > 0 ? TakeProfitPips : (double?)null;
ExecuteMarketOrder(direction, SymbolName, volume, Label, stopLoss, takeProfit);
}
}
}