//+------------------------------------------------------------------+
//| ImpliedDensityMonitor.mqh                                        |
//| CDensityEngine reads an option-implied probability distribution  |
//| from a published feed and rescales it onto a related local symbol|
//| Two limits travel with the number: the distribution is           |
//| risk-neutral, so it is what options charge rather than a forecast|
//| and the feed and local symbol need not be equivalent instruments.|
//| The rescaling is a monitoring convenience, not an equivalence.   |
//| Reads only. Never trades.                                        |
//+------------------------------------------------------------------+
#ifndef IMPLIED_DENSITY_MONITOR_MQH
#define IMPLIED_DENSITY_MONITOR_MQH

//--- the anchor of each expiry is remembered here between runs
#define GDEN_ANCHOR_PREFIX "GDEN_ANCHOR_"

//+------------------------------------------------------------------+
//| Where the current price sits inside the implied distribution     |
//+------------------------------------------------------------------+
enum ENUM_DENSITY_ZONE
  {
   ZONE_NONE,          // no usable reading yet
   ZONE_BELOW_TAIL,    // under the 5th percentile
   ZONE_LOWER,         // between the 5th and 25th
   ZONE_CENTRAL,       // between the 25th and 75th
   ZONE_UPPER,         // between the 75th and 95th
   ZONE_ABOVE_TAIL     // above the 95th percentile
  };

//+------------------------------------------------------------------+
//| Option-implied distribution engine                               |
//+------------------------------------------------------------------+
class CDensityEngine
  {
private:
   //--- configuration
   string            m_url;
   string            m_symbol;
   string            m_expectFeed;
   int               m_timeout;
   //--- published distribution, as multiples of the ETF forward
   double            m_forward;
   double            m_spot;
   double            m_r05;
   double            m_r25;
   double            m_r50;
   double            m_r75;
   double            m_r95;
   double            m_probAbove;
   double            m_move1sd;
   double            m_skew;
   double            m_kurt;
   double            m_benchSkew;
   double            m_benchKurt;
   double            m_negMass;
   double            m_sensKurtLo;
   double            m_sensKurtHi;
   int               m_days;
   string            m_expiry;
   string            m_feedSymbol;
   //--- mapped onto the local terminal price
   double            m_price;
   double            m_anchor;
   double            m_level05;
   double            m_level25;
   double            m_level50;
   double            m_level75;
   double            m_level95;
   ENUM_DENSITY_ZONE m_zone;
   //--- housekeeping
   string            m_updated;
   int               m_ageMinutes;
   string            m_lastError;

   //--- true when the key is present at all. A missing field and a field that
   //--- holds zero read the same through JsonNumber, so required fields are
   //--- checked for presence first
   bool              JsonHas(const string json, const string key)
     {
      return(StringFind(json, "\"" + key + "\"") >= 0);
     }

   //--- read a numeric field out of a flat JSON object
   double            JsonNumber(const string json, const string key)
     {
      string tag = "\"" + key + "\"";
      int at = StringFind(json, tag);
      if(at < 0)
         return 0.0;
      int colon = StringFind(json, ":", at + StringLen(tag));
      if(colon < 0)
         return 0.0;
      int end = colon + 1;
      int len = StringLen(json);
      while(end < len)
        {
         ushort ch = StringGetCharacter(json, end);
         if(ch == ',' || ch == '}' || ch == '\n' || ch == '\r')
            break;
         end++;
        }
      string raw = StringSubstr(json, colon + 1, end - colon - 1);
      StringTrimLeft(raw);
      StringTrimRight(raw);
      return StringToDouble(raw);
     }

   //--- read a text field out of a flat JSON object
   string            JsonText(const string json, const string key)
     {
      string tag = "\"" + key + "\"";
      int at = StringFind(json, tag);
      if(at < 0)
         return "";
      int colon = StringFind(json, ":", at + StringLen(tag));
      if(colon < 0)
         return "";
      int open = StringFind(json, "\"", colon + 1);
      if(open < 0)
         return "";
      int close = StringFind(json, "\"", open + 1);
      if(close < 0)
         return "";
      return StringSubstr(json, open + 1, close - open - 1);
     }

   //--- convert an ISO 8601 timestamp into a datetime value
   datetime          FeedTime(const string iso)
     {
      string s = iso;
      StringReplace(s, "-", ".");
      StringReplace(s, "T", " ");
      StringReplace(s, "Z", "");
      return StringToTime(s);
     }

   //--- download the feed; empty string means the reason is in m_lastError
   string            Download()
     {
      char post[], result[];
      string result_headers;
      ResetLastError();
      int status = WebRequest("GET", m_url, "", m_timeout, post, result, result_headers);
      if(status == -1)
        {
         int err = GetLastError();
         if(err == 4014)
            m_lastError = "URL not allowed in Tools -> Options -> Expert Advisors";
         else
            m_lastError = "download failed, error " + IntegerToString(err);
         return "";
        }
      if(status != 200)
        {
         m_lastError = "feed returned HTTP status " + IntegerToString(status);
         return "";
        }
      return CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
     }

   //--- the local price the distribution is rescaled from, fixed for the expiry
   //--- A distribution answers "where can price finish, starting from here". If the
   //--- levels were rebuilt around the latest price on every cycle, the band would
   //--- follow price, price would sit in the middle of it forever, and nothing could
   //--- ever be learned from it. So the anchor is set once per expiry and kept.
   //--- The percentile ratios still update with each feed, so the band edges move
   //--- when the options reprice, but they no longer move with the local price.
   double            ResolveAnchor()
     {
      string key = GDEN_ANCHOR_PREFIX + m_feedSymbol + "_" + m_symbol + "_" + m_expiry;
      if(GlobalVariableCheck(key))
        {
         double stored = GlobalVariableGet(key);
         if(stored > 0.0)
            return stored;
        }
      GlobalVariableSet(key, m_price);
      return m_price;
     }

   //--- decide which part of the distribution the current price occupies
   ENUM_DENSITY_ZONE Classify()
     {
      if(m_price <= 0.0)
         return ZONE_NONE;
      if(m_price < m_level05)
         return ZONE_BELOW_TAIL;
      if(m_price < m_level25)
         return ZONE_LOWER;
      if(m_price <= m_level75)
         return ZONE_CENTRAL;
      if(m_price <= m_level95)
         return ZONE_UPPER;
      return ZONE_ABOVE_TAIL;
     }

public:
                     CDensityEngine() : m_url(""), m_symbol("XAUUSD"), m_expectFeed(""),
                                        m_timeout(5000), m_forward(0), m_spot(0),
                                        m_r05(0), m_r25(0), m_r50(0), m_r75(0), m_r95(0),
                                        m_probAbove(0), m_move1sd(0), m_skew(0), m_kurt(0),
                                        m_benchSkew(0), m_benchKurt(0),
                                        m_negMass(0), m_sensKurtLo(0), m_sensKurtHi(0),
                                        m_days(0), m_expiry(""), m_feedSymbol(""),
                                        m_price(0), m_anchor(0), m_level05(0), m_level25(0),
                                        m_level50(0), m_level75(0), m_level95(0),
                                        m_zone(ZONE_NONE), m_updated(""),
                                        m_ageMinutes(-1), m_lastError("")
     {
     }

   //--- feed location, the local symbol the distribution is mapped onto, and the
   //--- optionable ETF the feed must describe. An empty expected symbol skips the
   //--- check; a set one catches a FeedURL that points at the wrong instrument
   void              Configure(const string url, const string symbol, const int timeout,
                               const string expectFeed = "")
     {
      m_url = url;
      m_symbol = symbol;
      m_expectFeed = expectFeed;
      m_timeout = (timeout < 1000 ? 1000 : timeout);
     }

   //--- forget the stored anchor, so the next reading starts from today's price
   void              ResetAnchor()
     {
      if(m_expiry == "")
         return;
      string key = GDEN_ANCHOR_PREFIX + m_feedSymbol + "_" + m_symbol + "_" + m_expiry;
      if(GlobalVariableCheck(key))
         GlobalVariableDel(key);
     }

   //--- fetch and map; false means LastError() explains why
   bool              Compute()
     {
      m_lastError = "";
      string json = Download();
      if(json == "")
         return false;

      //--- every field the class reads must be present, not merely non-zero,
      //--- so a truncated or renamed feed is reported by name instead of
      //--- showing a zero that looks like a reading
      string need[] = {"symbol", "expiry", "spot", "forward", "days_to_expiry",
                       "r05", "r25", "r50", "r75", "r95", "updated_utc",
                       "prob_above_spot", "implied_move_1sd_pct", "skewness",
                       "kurtosis", "bench_skewness", "bench_kurtosis",
                       "neg_mass_pct", "sens_kurt_min", "sens_kurt_max"};
      for(int i = 0; i < ArraySize(need); i++)
        {
         if(!JsonHas(json, need[i]))
           {
            m_lastError = "the feed has no " + need[i] + " field";
            return false;
           }
        }
      m_feedSymbol = JsonText(json, "symbol");
      if(m_expectFeed != "" && m_feedSymbol != m_expectFeed)
        {
         m_lastError = "the feed describes " + m_feedSymbol + ", expected " + m_expectFeed;
         return false;
        }

      m_forward = JsonNumber(json, "forward");
      m_spot = JsonNumber(json, "spot");
      m_r05 = JsonNumber(json, "r05");
      m_r25 = JsonNumber(json, "r25");
      m_r50 = JsonNumber(json, "r50");
      m_r75 = JsonNumber(json, "r75");
      m_r95 = JsonNumber(json, "r95");
      if(m_r05 <= 0.0 || m_r95 <= 0.0 || m_r05 >= m_r95 ||
         m_forward <= 0.0 || m_spot <= 0.0)
        {
         m_lastError = "the feed carried no usable distribution";
         return false;
        }

      m_probAbove = JsonNumber(json, "prob_above_spot");
      m_move1sd = JsonNumber(json, "implied_move_1sd_pct");
      m_skew = JsonNumber(json, "skewness");
      m_kurt = JsonNumber(json, "kurtosis");
      m_benchSkew = JsonNumber(json, "bench_skewness");
      m_benchKurt = JsonNumber(json, "bench_kurtosis");
      //--- diagnostics: how much repair the density needed, and how far the
      //--- shape moves under other reasonable specifications of the fit
      m_negMass = JsonNumber(json, "neg_mass_pct");
      m_sensKurtLo = JsonNumber(json, "sens_kurt_min");
      m_sensKurtHi = JsonNumber(json, "sens_kurt_max");
      m_days = (int)JsonNumber(json, "days_to_expiry");
      m_expiry = JsonText(json, "expiry");
      m_updated = JsonText(json, "updated_utc");

      m_price = SymbolInfoDouble(m_symbol, SYMBOL_BID);
      if(m_price <= 0.0)
        {
         m_lastError = "no price for " + m_symbol + ", open its chart once";
         return false;
        }
      //--- The feed publishes percentiles as multiples of the ETF forward, while
      //--- the anchor is a spot-like local price. Multiplying by forward / spot
      //--- first re-expresses each percentile as a multiple of the ETF spot, so
      //--- the ETF's own carry is not mistaken for part of the distribution.
      //--- The local symbol's carry, sessions and funding remain its own
      double scale = m_forward / m_spot;
      //--- the levels hang off the anchor, not off the latest tick
      m_anchor = ResolveAnchor();
      m_level05 = m_anchor * scale * m_r05;
      m_level25 = m_anchor * scale * m_r25;
      m_level50 = m_anchor * scale * m_r50;
      m_level75 = m_anchor * scale * m_r75;
      m_level95 = m_anchor * scale * m_r95;
      m_zone = Classify();

      datetime stamp = FeedTime(m_updated);
      m_ageMinutes = (stamp > 0 ? (int)((TimeGMT() - stamp) / 60) : -1);
      return true;
     }

   //--- re-read the local price and re-classify it against the levels already
   //--- held, without downloading anything. Called on every redraw, so the zone
   //--- and the alert follow the price between feed reads
   bool              Refresh()
     {
      if(m_anchor <= 0.0)
         return false;
      double bid = SymbolInfoDouble(m_symbol, SYMBOL_BID);
      if(bid <= 0.0)
         return false;
      m_price = bid;
      m_zone = Classify();
      return true;
     }

   //--- results
   double            Price()      const { return m_price; }        // local price now
   double            Anchor()     const { return m_anchor; }       // price the band hangs from
   double            Level05()    const { return m_level05; }      // 5th percentile
   double            Level25()    const { return m_level25; }      // 25th percentile
   double            Level50()    const { return m_level50; }      // implied median
   double            Level75()    const { return m_level75; }      // 75th percentile
   double            Level95()    const { return m_level95; }      // 95th percentile
   double            ProbAbove()  const { return m_probAbove; }    // 0.5 = even odds
   double            Move1sd()    const { return m_move1sd; }      // percent of forward
   double            Skewness()   const { return m_skew; }         // shape of the density
   double            Kurtosis()   const { return m_kurt; }         // compare with BenchKurt
   double            BenchSkew()  const { return m_benchSkew; }    // lognormal reference
   double            BenchKurt()  const { return m_benchKurt; }    // lognormal reference
   double            NegMass()    const { return m_negMass; }      // percent clipped away
   double            SensKurtLo() const { return m_sensKurtLo; }   // kurtosis across specs
   double            SensKurtHi() const { return m_sensKurtHi; }   // kurtosis across specs
   int               Days()       const { return m_days; }         // days to expiry
   string            Expiry()     const { return m_expiry; }       // expiry read
   string            FeedSymbol() const { return m_feedSymbol; }   // options instrument
   ENUM_DENSITY_ZONE Zone()       const { return m_zone; }         // where price sits
   string            UpdatedUTC() const { return m_updated; }      // feed timestamp
   int               AgeMinutes() const { return m_ageMinutes; }   // -1 when unknown
   string            LastError()  const { return m_lastError; }    // why Compute() failed

   //--- how far price has travelled from the anchor, in percent
   double            DriftPct()
     {
      if(m_anchor <= 0.0)
         return 0.0;
      return 100.0 * (m_price - m_anchor) / m_anchor;
     }

   //--- headline for the panel, in words
   string            ZoneHeadline()
     {
      switch(m_zone)
        {
         case ZONE_BELOW_TAIL:
            return "Below the implied 5 percent tail";
         case ZONE_LOWER:
            return "In the lower quarter of the range";
         case ZONE_CENTRAL:
            return "Inside the central half of the range";
         case ZONE_UPPER:
            return "In the upper quarter of the range";
         case ZONE_ABOVE_TAIL:
            return "Above the implied 95 percent tail";
         default:
            return "No reading";
        }
     }

   //--- the second line, explaining the headline
   string            ZoneExplain()
     {
      switch(m_zone)
        {
         case ZONE_BELOW_TAIL:
         case ZONE_ABOVE_TAIL:
            return StringFormat("Outside the risk-neutral 5 to 95 band of %s options",
                                m_feedSymbol);
         case ZONE_LOWER:
         case ZONE_UPPER:
            return "Inside the 5 to 95 band, outside the 25 to 75";
         case ZONE_CENTRAL:
            return StringFormat("Inside the risk-neutral 25 to 75 band of %s options",
                                m_feedSymbol);
         default:
            return "Waiting for the feed";
        }
     }

   //--- true when the price sits outside the 5 to 95 band
   bool              InTail()
     {
      return (m_zone == ZONE_BELOW_TAIL || m_zone == ZONE_ABOVE_TAIL);
     }

   //--- true when the price sits outside the 25 to 75 band, the more stable
   //--- edges, for callers that prefer to alert on those
   bool              OutsideCentral()
     {
      return (m_zone != ZONE_NONE && m_zone != ZONE_CENTRAL);
     }

   //--- one plain sentence, short enough for a push notification
   string            Summary()
     {
      return StringFormat("%s. From %.0f, %s options implied a risk-neutral %d day 5 to 95 band of %.0f to %.0f, and %s is at %.0f.",
                          ZoneHeadline(), m_anchor, m_feedSymbol, m_days, m_level05,
                          m_level95, m_symbol, m_price);
     }
  };

#endif // IMPLIED_DENSITY_MONITOR_MQH
//+------------------------------------------------------------------+
