Fed Model
Trading rules
Universe order is [equity ETF, optional cash ETF]. Use only daily candles and the first security's exchange calendar. At the last trading day's close of each month, require RegressionMonths consecutive monthly observations published by that close. Regress y = EarningsYield − BondYield on x = 0..N−1 with an intercept: slope = Σ((x−mean(x))×(y−mean(y))) / Σ(x−mean(x))²; forecast = mean(y) + slope×(N−mean(x)). Positive holds equities; negative holds the cash ETF or uninvested cash. Zero leaves the previous regime unchanged. Trade only when the forecast regime changes, without monthly resizing within one regime. Fully liquidate the old regime before buying the next.
All observations must be published by the signal candle close. Intraday execution uses daily prices strictly before its UTC date; a finished one-day candle contributes its own completed date. Never warm a decision with future CSV dates.
The publication cutoff is the latest actual CloseTime among synchronized finished candles. A trading session can finish before the configured candle interval has elapsed.
Input data
YieldHistoryFile is required CSV: Month,PublishedAt,EarningsYield,BondYield. Month uses yyyy-MM; PublishedAt uses UTC yyyy-MM-ddTHH:mm:ssZ. Both yields use the same units. Only revisions published by the decision close are eligible. Require consecutive months ending in the decision month; a missing observation delays that month's forecast. Duplicate revisions and publication timestamps before the observation month are rejected. Series come from observed market/fundamental data, not inverse prices or candle ranges.
Parameters
| Parameter | Default |
|---|---|
Universe |
required |
Capital |
100000 |
MinTradeUsd |
100 |
CandleType |
TimeSpan.FromDays(1).TimeFrame() |
RegressionMonths |
12 |
YieldHistoryFile |
required |
Execution
Universe is explicit. Execution prices and Capital use USD per traded unit. Long-only exposure is capped at min(Capital, reported Portfolio.CurrentValue); an unavailable/nonpositive portfolio value leaves no buying budget. No leverage, stop loss or take profit.
Round target quantities toward zero to VolumeStep. Wait for actual liquidation fills before opening new positions; reconcile actual positions, respect MinVolume/MaxVolume and split large adjustments across later candles. MinTradeUsd filters nonzero target adjustments, while full liquidation bypasses it.
Verification
Tests run both languages using packaged BTC/TON execution data and explicit rate, financial, score, text and warmup fixtures. Calendar fixtures relocate archive OHLC/volume and provide deterministic execution quotes; they are not multi-year market history. Independent formulas, order direction/size, actual fills, residual positions and forbidden decision dates are checked. Two buckets, K=1 and AssetDecile=1 are declared mechanics profiles, not evidence of the complete production universe. No profitability claim is made.
namespace StockSharp.Samples.Strategies;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Ecng.Common;
using Ecng.ComponentModel;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
/// <summary>Fed Model with point-in-time inputs and execution-based portfolio accounting.</summary>
public class FedModelStrategy : Strategy
{
private readonly StrategyParam<IEnumerable<Security>> _universe;
private readonly StrategyParam<decimal> _capital;
private readonly StrategyParam<decimal> _minTradeUsd;
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<int> _regressionMonths;
private readonly StrategyParam<string> _yieldHistoryFile;
private Security[] _securities = [];
private readonly Dictionary<Security, (DateTime time, decimal price, DateTime closed)> _latest = [];
private readonly Dictionary<Security, decimal> _targets = [];
private readonly List<Order> _pending = [];
private int _lastRebalance;
private DateTime? _lastBar;
private readonly Dictionary<Security, decimal> _nextTargets = [];
private bool _isRotating;
private bool _isReconciling;
private sealed record Yield(DateTime Month, DateTime Published, decimal Spread);
private readonly List<Yield> _yields = [];
private bool? _equityRegime;
/// <summary>Explicit execution securities with valid native volume metadata.</summary>
public IEnumerable<Security> Universe { get => _universe.Value; set => _universe.Value = value; }
/// <summary>Fixed capital budget in USD.</summary>
public decimal Capital { get => _capital.Value; set => _capital.Value = value; }
/// <summary>Minimum USD adjustment; complete liquidation bypasses this filter.</summary>
public decimal MinTradeUsd { get => _minTradeUsd.Value; set => _minTradeUsd.Value = value; }
/// <summary>Finished execution candle type.</summary>
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
/// <summary>Consecutive monthly yield spreads in the OLS forecast.</summary>
public int RegressionMonths { get => _regressionMonths.Value; set => _regressionMonths.Value = value; }
/// <summary>Required point-in-time monthly equity earnings and ten-year bond yields.</summary>
public string YieldHistoryFile { get => _yieldHistoryFile.Value; set => _yieldHistoryFile.Value = value; }
/// <summary>Initialize the strategy parameters.</summary>
public FedModelStrategy()
{
_universe = Param<IEnumerable<Security>>(nameof(Universe)).SetRequired()
.SetDisplay("Universe", "Explicit execution securities with valid native volume metadata", "Parameters");
_capital = Param(nameof(Capital), 100000m).SetGreaterThanZero()
.SetDisplay("Capital", "Fixed capital budget in USD", "Parameters");
_minTradeUsd = Param(nameof(MinTradeUsd), 100m).SetRange(0m, decimal.MaxValue)
.SetDisplay("MinTradeUsd", "Minimum USD adjustment; complete liquidation bypasses this filter", "Parameters");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1440).TimeFrame())
.SetDisplay("CandleType", "Finished execution candle type", "Parameters");
_regressionMonths = Param(nameof(RegressionMonths), 12).SetRange(2, 120)
.SetDisplay("RegressionMonths", "Consecutive monthly yield spreads in the OLS forecast", "Parameters");
_yieldHistoryFile = Param<string>(nameof(YieldHistoryFile)).SetRequired()
.SetDisplay("YieldHistoryFile", "Required point-in-time monthly equity earnings and ten-year bond yields", "Parameters");
Trades.TradeAdded += _ => ContinueRotation();
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> (Universe ?? []).Where(s => s is not null).DistinctBy(s => s.Id, StringComparer.OrdinalIgnoreCase).Select(s => (s, CandleType));
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_securities = [];
_latest.Clear();
_targets.Clear();
_pending.Clear();
_lastRebalance = 0;
_lastBar = null;
_nextTargets.Clear();
_isRotating = false;
_isReconciling = false;
_yields.Clear();
_equityRegime = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
_securities = (Universe ?? []).ToArray();
if (_securities.Length == 0 || _securities.Any(s => s is null || string.IsNullOrWhiteSpace(s.Id))
|| _securities.Select(s => s.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count() != _securities.Length)
throw new InvalidOperationException("Universe must contain distinct execution securities.");
foreach (var security in _securities)
{
if (security.VolumeStep is not > 0m || security.MinVolume is not > 0m || security.MaxVolume is not > 0m)
throw new InvalidOperationException("Every security needs positive VolumeStep, MinVolume and MaxVolume.");
}
if (_securities.Length > 2 || CandleType.Arg is not TimeSpan frame || frame != TimeSpan.FromDays(1) || _securities.Any(s => s.Board is null))
throw new InvalidOperationException("The Fed model requires daily candles, an equity security and at most one cash security, with exchange calendars.");
LoadYields();
base.OnStarted2(time);
foreach (var security in _securities)
SubscribeCandles(CandleType, security: security).Bind(candle => ProcessCandle(security, candle)).Start();
}
private void ProcessCandle(Security security, ICandleMessage candle)
{
if (candle.State != CandleStates.Finished || candle.ClosePrice <= 0m) return;
_latest[security] = (candle.OpenTime, candle.ClosePrice, candle.CloseTime);
if (_latest.Count != _securities.Length || _latest.Values.Any(v => v.time != candle.OpenTime)
|| _lastBar == candle.OpenTime || !IsFormedAndOnlineAndAllowTrading()) return;
_lastBar = candle.OpenTime;
var month = candle.OpenTime.Year * 100 + candle.OpenTime.Month;
if (!_isRotating && _lastRebalance != month && SelectTargets(candle.OpenTime, _latest.Values.Max(v => v.closed)))
_lastRebalance = month;
ReconcileTargets();
}
private decimal Budget => Math.Max(0m, Math.Min(Capital, Portfolio.CurrentValue.GetValueOrDefault()));
private decimal UnitPrice(Security security) => _latest[security].price;
private decimal Units(Security security, decimal notional)
=> Math.Floor(notional / UnitPrice(security) / security.VolumeStep.Value) * security.VolumeStep.Value;
private void ReconcileTargets()
{
if (_isReconciling) return;
if (_pending.Any(o => o.State is not (OrderStates.Done or OrderStates.Failed))) return;
_pending.Clear();
_isReconciling = true;
try
{
foreach (var security in _securities)
{
if (!_targets.TryGetValue(security, out var target)) continue;
var delta = target - GetPositionValue(security, Portfolio).GetValueOrDefault();
if (delta == 0m || (target != 0m && Math.Abs(delta) * UnitPrice(security) < MinTradeUsd)) continue;
var step = security.VolumeStep.Value;
var quantity = Math.Floor(Math.Min(Math.Abs(delta), security.MaxVolume.Value) / step) * step;
if (quantity < security.MinVolume.Value) continue;
var order = new Order
{
Security = security, Portfolio = Portfolio, Type = OrderTypes.Market,
Side = delta > 0m ? Sides.Buy : Sides.Sell, Volume = quantity, Comment = "0372Rebalance",
};
_pending.Add(order);
RegisterOrder(order);
}
}
finally
{
_isReconciling = false;
}
ContinueRotation();
}
private void BeginRotation(Dictionary<Security, decimal> targets)
{
_nextTargets.Clear();
foreach (var target in targets) _nextTargets[target.Key] = target.Value;
foreach (var security in _securities) _targets[security] = 0m;
_isRotating = true;
}
private void ContinueRotation()
{
if (!_isRotating || _isReconciling || !IsFormedAndOnlineAndAllowTrading()
|| _pending.Any(o => o.State is not (OrderStates.Done or OrderStates.Failed))
|| _securities.Any(s => GetPositionValue(s, Portfolio).GetValueOrDefault() != 0m)) return;
_isRotating = false;
foreach (var security in _securities) _targets[security] = _nextTargets.GetValueOrDefault(security);
_nextTargets.Clear();
ReconcileTargets();
}
private static DateTime ParseUtc(string value)
=> DateTime.ParseExact(value.Trim(), "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
private static IEnumerable<string[]> ReadRows(string path, string header, int fields)
{
using var lines = File.ReadLines(path).GetEnumerator();
if (!lines.MoveNext() || lines.Current.Trim() != header)
throw new InvalidOperationException("Invalid CSV header: expected " + header + ".");
while (lines.MoveNext())
{
if (string.IsNullOrWhiteSpace(lines.Current)) continue;
var row = lines.Current.Split(',');
if (row.Length != fields) throw new InvalidOperationException("Invalid CSV field count.");
yield return row;
}
}
private void LoadYields()
{
var keys = new HashSet<(DateTime, DateTime)>();
foreach (var row in ReadRows(YieldHistoryFile, "Month,PublishedAt,EarningsYield,BondYield", 4))
{
var month = DateTime.ParseExact(row[0].Trim(), "yyyy-MM", CultureInfo.InvariantCulture);
var published = ParseUtc(row[1]);
var earnings = decimal.Parse(row[2], CultureInfo.InvariantCulture);
var bond = decimal.Parse(row[3], CultureInfo.InvariantCulture);
if (published.Date < month || !keys.Add((month, published)))
throw new InvalidOperationException("Monthly yield observations need causal, unique publication dates.");
_yields.Add(new(month, published, earnings - bond));
}
}
private bool SelectTargets(DateTime time, DateTime asOf)
{
var calendar = _securities[0].Board.WorkingTime;
if (!calendar.IsWorkingDate(time.Date, true)) return false;
for (var next = time.Date.AddDays(1); next.Month == time.Month; next = next.AddDays(1))
if (calendar.IsWorkingDate(next, true)) return false;
var month = new DateTime(time.Year, time.Month, 1);
var y = new List<decimal>();
for (var i = RegressionMonths - 1; i >= 0; i--)
{
var observation = _yields.Where(v => v.Month == month.AddMonths(-i) && v.Published <= asOf)
.OrderByDescending(v => v.Published).FirstOrDefault();
if (observation is null) return false;
y.Add(observation.Spread);
}
var meanX = (RegressionMonths - 1m) / 2m;
var meanY = y.Average();
var variance = Enumerable.Range(0, RegressionMonths).Sum(i => (i - meanX) * (i - meanX));
var slope = y.Select((v, i) => (i - meanX) * (v - meanY)).Sum() / variance;
var prediction = meanY + slope * (RegressionMonths - meanX);
if (prediction == 0m) return false;
var equity = prediction > 0m;
if (_equityRegime == equity) return true;
_equityRegime = equity;
var security = equity ? _securities[0] : _securities.ElementAtOrDefault(1);
var targets = new Dictionary<Security, decimal>();
if (security is not null) targets[security] = Units(security, Budget);
BeginRotation(targets);
return true;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("Ecng.ComponentModel")
from System import Boolean, DateTime, Decimal, Int32, Math, TimeSpan, InvalidOperationException
from System.Collections.Generic import IEnumerable
from System.Globalization import CultureInfo, DateTimeStyles
from System.IO import File, Path
from Ecng.ComponentModel import WorkingTimeExtensions
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Order, Security
from StockSharp.Messages import CandleStates, DataType, OrderStates, OrderTypes, Sides
class fed_model_strategy(Strategy):
"""Fed Model with point-in-time inputs and execution-based portfolio accounting."""
def __init__(self):
super(fed_model_strategy, self).__init__()
self._universe = self.Param[IEnumerable[Security]]("Universe", None).SetRequired() \
.SetDisplay("Universe", "Explicit execution securities with valid native volume metadata", "Parameters")
self._capital = self.Param("Capital", Decimal(100000)).SetGreaterThanZero() \
.SetDisplay("Capital", "Fixed capital budget in USD", "Parameters")
self._min_trade_usd = self.Param("MinTradeUsd", Decimal(100)).SetRange(Decimal(0), Decimal.MaxValue) \
.SetDisplay("MinTradeUsd", "Minimum USD adjustment; complete liquidation bypasses this filter", "Parameters")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1440))) \
.SetDisplay("CandleType", "Finished execution candle type", "Parameters")
self._regression_months = self.Param("RegressionMonths", 12).SetRange(2, 120) \
.SetDisplay("RegressionMonths", "Consecutive monthly yield spreads in the OLS forecast", "Parameters")
self._yield_history_file = self.Param[str]("YieldHistoryFile", None).SetRequired() \
.SetDisplay("YieldHistoryFile", "Required point-in-time monthly equity earnings and ten-year bond yields", "Parameters")
self._clear_state()
self.Trades.TradeAdded += self._on_trade
def _clear_state(self):
self._securities = []
self._latest = {}
self._targets = {}
self._pending = []
self._last_rebalance = 0
self._last_bar = None
self._next_targets = {}
self._is_rotating = False
self._is_reconciling = False
self._yields = []
self._equity_regime = None
@property
def Universe(self):
return self._universe.Value
@Universe.setter
def Universe(self, value):
self._universe.Value = value
@property
def Capital(self):
return self._capital.Value
@Capital.setter
def Capital(self, value):
self._capital.Value = value
@property
def MinTradeUsd(self):
return self._min_trade_usd.Value
@MinTradeUsd.setter
def MinTradeUsd(self, value):
self._min_trade_usd.Value = value
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def RegressionMonths(self):
return self._regression_months.Value
@RegressionMonths.setter
def RegressionMonths(self, value):
self._regression_months.Value = value
@property
def YieldHistoryFile(self):
return self._yield_history_file.Value
@YieldHistoryFile.setter
def YieldHistoryFile(self, value):
self._yield_history_file.Value = value
def GetWorkingSecurities(self):
return [(s, self.CandleType) for s in self.Universe] if self.Universe is not None else []
def OnReseted(self):
super(fed_model_strategy, self).OnReseted()
self._clear_state()
def OnStarted2(self, time):
self._securities = list(self.Universe) if self.Universe is not None else []
if not self._securities or any(s is None or not s.Id for s in self._securities) or len(set(s.Id.upper() for s in self._securities)) != len(self._securities):
raise InvalidOperationException("Universe must contain distinct execution securities.")
for security in self._securities:
if security.VolumeStep is None or security.MinVolume is None or security.MaxVolume is None or security.VolumeStep <= 0 or security.MinVolume <= 0 or security.MaxVolume <= 0:
raise InvalidOperationException("Every security needs positive VolumeStep, MinVolume and MaxVolume.")
if len(self._securities) > 2 or self.CandleType.Arg != TimeSpan.FromDays(1) or any(s.Board is None for s in self._securities):
raise InvalidOperationException("The Fed model requires daily candles, an equity security and at most one cash security, with exchange calendars.")
self._load_yields()
super(fed_model_strategy, self).OnStarted2(time)
for security in self._securities:
self.SubscribeCandles(self.CandleType, True, security).Bind(lambda candle, s=security: self._process_candle(s, candle)).Start()
def _process_candle(self, security, candle):
if candle.State != CandleStates.Finished or candle.ClosePrice <= 0:
return
self._latest[security] = (candle.OpenTime, candle.ClosePrice, candle.CloseTime)
if len(self._latest) != len(self._securities) or any(value[0] != candle.OpenTime for value in self._latest.values()) or self._last_bar == candle.OpenTime or not self.IsFormedAndOnlineAndAllowTrading():
return
self._last_bar = candle.OpenTime
month = candle.OpenTime.Year * 100 + candle.OpenTime.Month
if not self._is_rotating and self._last_rebalance != month and self._select_targets(candle.OpenTime, max(value[2] for value in self._latest.values())):
self._last_rebalance = month
self._reconcile_targets()
def _budget(self):
return max(Decimal(0), min(self.Capital, self.Portfolio.CurrentValue if self.Portfolio.CurrentValue is not None else Decimal(0)))
def _unit_price(self, security):
return self._latest[security][1]
def _units(self, security, notional):
return Math.Floor(notional / self._unit_price(security) / security.VolumeStep) * security.VolumeStep
def _reconcile_targets(self):
if self._is_reconciling:
return
if any(o.State != OrderStates.Done and o.State != OrderStates.Failed for o in self._pending):
return
self._pending = []
self._is_reconciling = True
try:
for security in self._securities:
if security not in self._targets:
continue
target = self._targets[security]
position = self.GetPositionValue(security, self.Portfolio)
delta = target - (position if position is not None else Decimal(0))
if delta == 0 or (target != 0 and abs(delta) * self._unit_price(security) < self.MinTradeUsd):
continue
quantity = Math.Floor(min(abs(delta), security.MaxVolume) / security.VolumeStep) * security.VolumeStep
if quantity < security.MinVolume:
continue
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Type = OrderTypes.Market
order.Side = Sides.Buy if delta > 0 else Sides.Sell
order.Volume = quantity
order.Comment = "0372Rebalance"
self._pending.append(order)
self.RegisterOrder(order)
finally:
self._is_reconciling = False
self._continue_rotation()
def _begin_rotation(self, targets):
self._next_targets = targets
self._targets = {s: Decimal(0) for s in self._securities}
self._is_rotating = True
def _on_trade(self, trade):
self._continue_rotation()
def _continue_rotation(self):
if not self._is_rotating or self._is_reconciling or not self.IsFormedAndOnlineAndAllowTrading():
return
if any(o.State != OrderStates.Done and o.State != OrderStates.Failed for o in self._pending):
return
if any((self.GetPositionValue(s, self.Portfolio) or Decimal(0)) != 0 for s in self._securities):
return
self._is_rotating = False
self._targets = {s: self._next_targets.get(s, Decimal(0)) for s in self._securities}
self._next_targets = {}
self._reconcile_targets()
@staticmethod
def _parse_utc(value):
return DateTime.ParseExact(value.strip(), "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
@staticmethod
def _read_rows(path, header, fields):
lines = list(File.ReadLines(path))
if not lines or lines[0].strip() != header:
raise InvalidOperationException("Invalid CSV header: expected " + header + ".")
for line in lines[1:]:
if not line.strip():
continue
row = line.split(",")
if len(row) != fields:
raise InvalidOperationException("Invalid CSV field count.")
yield row
def _load_yields(self):
keys = set()
for row in self._read_rows(self.YieldHistoryFile, "Month,PublishedAt,EarningsYield,BondYield", 4):
month = DateTime.ParseExact(row[0].strip(), "yyyy-MM", CultureInfo.InvariantCulture)
published = self._parse_utc(row[1])
earnings = Decimal.Parse(row[2], CultureInfo.InvariantCulture)
bond = Decimal.Parse(row[3], CultureInfo.InvariantCulture)
key = (month, published)
if published.Date < month or key in keys:
raise InvalidOperationException("Monthly yield observations need causal, unique publication dates.")
keys.add(key)
self._yields.append((month, published, earnings - bond))
def _select_targets(self, time, as_of):
calendar = self._securities[0].Board.WorkingTime
if not WorkingTimeExtensions.IsWorkingDate(calendar, time.Date, True):
return False
following = time.Date.AddDays(1)
while following.Month == time.Month:
if WorkingTimeExtensions.IsWorkingDate(calendar, following, True):
return False
following = following.AddDays(1)
month = DateTime(time.Year, time.Month, 1)
y = []
for i in range(self.RegressionMonths - 1, -1, -1):
available = [v for v in self._yields if v[0] == month.AddMonths(-i) and v[1] <= as_of]
if not available:
return False
y.append(max(available, key=lambda v: v[1])[2])
mean_x = Decimal(self.RegressionMonths - 1) / Decimal(2)
mean_y = sum(y, Decimal(0)) / Decimal(len(y))
variance = sum(((Decimal(i) - mean_x) * (Decimal(i) - mean_x) for i in range(len(y))), Decimal(0))
slope = sum(((Decimal(i) - mean_x) * (v - mean_y) for i, v in enumerate(y)), Decimal(0)) / variance
prediction = mean_y + slope * (Decimal(self.RegressionMonths) - mean_x)
if prediction == 0:
return False
equity = prediction > 0
if self._equity_regime == equity:
return True
self._equity_regime = equity
security = self._securities[0] if equity else self._securities[1] if len(self._securities) > 1 else None
self._begin_rotation({security: self._units(security, self._budget())} if security is not None else {})
return True
def CreateClone(self):
return fed_model_strategy()