// ============================================================================
// P1FastLucidEval25k.cs — NinjaTrader 8 strategy wrapper around P1FastCore.cs
// (P1 Fast Method — LucidPro 25K eval port; replaces the TV -> webhook ->
//  PickMyTrade -> Tradovate chain with native NT8 automation).
//
// INSTALL: copy BOTH files (P1FastCore.cs + this file) into
//   Documents\NinjaTrader 8\bin\Custom\Strategies\
// then in NT8: New > NinjaScript Editor > right-click > Compile (F5).
//
// CHART / BACKTEST REQUIREMENTS (see nt8/NT8-GUIDE.md for the full protocol):
//   * Instrument MES (micro E-mini S&P), data series = 1 MINUTE. The strategy
//     refuses to trade on any other bar type/size.
//   * The method's clock is US/Eastern wall time. Bars arrive in the time zone
//     NT8 is configured for (Tools > Options > General > Time zone), which
//     defaults to your PC's local zone; the strategy converts Local -> Eastern.
//     If your NT8 time zone setting is NOT your PC zone, put that zone's .NET
//     id in the "Bars time zone id" parameter.
//   * Order semantics mirror the TV/engine port: entry = limit at the OR
//     level, good for 30 minutes; full-size fixed limit target at +2R; stop
//     to breakeven after a CLOSED 1m bar (after the fill bar) touches +1R;
//     hard flat 15:55 ET (12:55 on CME early closes).
//   * Protective exits are ALSO submitted immediately on the entry execution
//     (OnExecutionUpdate), so live you are never naked for the fill minute.
//
// CAVEATS THAT TRAVEL WITH EVERY BACKTEST NUMBER (AGENTS.md §2/§5): the
// reference books use an optimistic fill model (touch fills, zero net
// slippage); NT8's own fill engine differs in the same-bar SL/TP ordering.
// Sim results are ceilings, not expected live results.
// ============================================================================

#region Using declarations
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using NinjaTrader.Cbi;
using NinjaTrader.Data;
using NinjaTrader.NinjaScript;
using P1Fast;
#endregion

namespace NinjaTrader.NinjaScript.Strategies
{
    public class P1FastLucidEval25k : Strategy
    {
        private P1FastCore core;
        private BracketPlan plan;          // null when no signal is being worked
        private Order entryOrder;          // working entry limit (null otherwise)
        private long pendingDeadline;      // last bar-OPEN AbsMin that may fill
        private int planQty;
        private int filledQty;             // accumulated entry-execution quantity
        private int exitFilledQty;         // accumulated TP/SL/EOD execution quantity
        private bool eodActive;            // last closed bar was in the 15:55/12:55 flat window
        private bool wasInPos;
        private int lastDayId = -1;
        private double realizedDayBase;
        private bool badSeries;
        private TimeZoneInfo easternTz, sourceTz;

        private const string SigLong = "P1L", SigShort = "P1S";

        protected override void OnStateChange()
        {
            if (State == State.SetDefaults)
            {
                Name = "P1FastLucidEval25k";
                Description = "P1 Fast Method (ORB or5, notOB, pad 0.25, M1be) — LucidPro 25K eval port of pine/Winners/p1_fast_lucidpro_eval_25k.pine";
                Calculate = Calculate.OnBarClose;      // pine executes on bar close
                EntriesPerDirection = 1;
                EntryHandling = EntryHandling.AllEntries;
                IsExitOnSessionCloseStrategy = true;   // backstop only; the method flattens itself 15:55
                ExitOnSessionCloseSeconds = 30;
                Slippage = 0;
                StartBehavior = StartBehavior.WaitUntilFlat;
                TimeInForce = TimeInForce.Gtc;
                TraceOrders = false;
                RealtimeErrorHandling = RealtimeErrorHandling.StopCancelClose;
                BarsRequiredToTrade = 0;               // core gates itself (needs prior-day levels)
                IsInstantiatedOnEachOptimizationIteration = true;

                RiskUsd = 800;
                RsiGate = RsiGateMode.NotOB;
                PadAtr = 0.25;
                DailyStopR = 2.0;
                CapQty = 20;
                LiveMode = false;
                LiveNeeded = 1250;
                LiveRoom = 1000;
                LiveDiv = 1.7;
                TradeFrom = new DateTime(2000, 1, 1);
                TradeTo = new DateTime(2099, 1, 1);
                BarsTimeZoneId = "";
                LogSignals = true;
            }
            else if (State == State.Configure)
            {
                // nothing extra — single 1m series only
            }
            else if (State == State.Realtime)
            {
                // MANDATORY historical -> realtime order-reference transition
                // (NT8 advanced order handling): cancelling/amending a stale
                // historical Order object can disable the strategy.
                if (entryOrder != null)
                    entryOrder = GetRealtimeOrder(entryOrder);
            }
            else if (State == State.DataLoaded)
            {
                badSeries = BarsPeriod.BarsPeriodType != BarsPeriodType.Minute
                            || BarsPeriod.Value != 1;
                if (badSeries)
                {
                    Log("P1FastLucidEval25k requires a 1-MINUTE data series; strategy is disabled on this chart.", LogLevel.Error);
                    Print("P1FastLucidEval25k: wrong series (need 1 minute). No trades will be taken.");
                }
                // hard MES guard: the method is built, sized and verified on
                // MES only (tick $1.25, point $5). Refuse anything else.
                if (!badSeries && (Instrument.MasterInstrument.Name != "MES"
                    || Math.Abs(Instrument.MasterInstrument.PointValue - 5.0) > 1e-9))
                {
                    badSeries = true;
                    Log($"P1FastLucidEval25k is MES-only (got {Instrument.MasterInstrument.Name}, point value {Instrument.MasterInstrument.PointValue}); strategy is disabled.", LogLevel.Error);
                    Print("P1FastLucidEval25k: not MES — no trades will be taken.");
                }

                easternTz = FindTz("America/New_York", "Eastern Standard Time");
                sourceTz = string.IsNullOrWhiteSpace(BarsTimeZoneId)
                    ? TimeZoneInfo.Local
                    : TimeZoneInfo.FindSystemTimeZoneById(BarsTimeZoneId);

                var p = new P1FastParams
                {
                    TickSize = Instrument.MasterInstrument.TickSize,
                    PointValue = Instrument.MasterInstrument.PointValue,
                    RiskUsd = RiskUsd,
                    RsiGate = RsiGate,
                    PadAtr = PadAtr,
                    DailyStopR = DailyStopR,
                    CapQty = CapQty,
                    LiveMode = LiveMode,
                    LiveNeeded = LiveNeeded,
                    LiveRoom = LiveRoom,
                    LiveDiv = LiveDiv,
                    TradeFromAbsMin = AbsMin(TradeFrom),
                    TradeToAbsMin = AbsMin(TradeTo),
                };
                core = new P1FastCore(p);
            }
        }

        protected override void OnBarUpdate()
        {
            if (badSeries || core == null || BarsInProgress != 0) return;

            // NT8 stamps bars with their CLOSE time; the core wants OPEN time ET.
            DateTime closeLocal = Time[0];
            DateTime openEt = ToEastern(closeLocal).AddMinutes(-1);
            var bar = new BarInput
            {
                AbsMin = openEt.Ticks / TimeSpan.TicksPerMinute,
                DayId = openEt.Year * 10000 + openEt.Month * 100 + openEt.Day,
                Tmin = openEt.Hour * 60 + openEt.Minute,
                O = Open[0], H = High[0], L = Low[0], C = Close[0], V = Volume[0],
            };

            // ---- day PnL baseline (pine dayStartNP resets at ET midnight)
            double cum = SystemPerformance.AllTrades.TradesPerformance.Currency.CumProfit;
            if (bar.DayId != lastDayId) realizedDayBase = cum;

            // ---- host management (pine 626-749): fill / close detection + BE
            bool inPosNow = Position.MarketPosition != MarketPosition.Flat;
            if (inPosNow && !wasInPos && plan != null)
                plan.MarkFilled(CurrentBar);
            if (!inPosNow && wasInPos)
            { plan = null; filledQty = 0; exitFilledQty = 0; }   // trade closed since last bar
            if (inPosNow && plan != null)
            {
                if (plan.OnBarClosed(CurrentBar, High[0], Low[0]) && LogSignals)
                    Print($"{openEt:yyyy-MM-dd HH:mm} +1R touched -> SL to breakeven {plan.StopPrice}");
            }

            bool pendingEntry = entryOrder != null && IsWorking(entryOrder);
            var host = new HostState
            {
                InPosition = inPosNow,
                PendingEntry = pendingEntry,
                RealizedDayPnl = cum - realizedDayBase,
                RealizedDayPnlForCap = cum - realizedDayBase,   // NT8: one account figure for both guards
            };

            var o = core.OnBar(bar, host);
            eodActive = o.FlatNow;      // read by OnExecutionUpdate for sneak fills

            if (o.FlatNow)
            {
                // pine 1147-1156: cancel everything, flatten, reset state.
                // NOTE: `plan` is NOT cleared on the cancel request — a limit
                // can still fill while the cancel is pending (NT8 CancelOrder
                // doc); plan is cleared only when the order reaches a terminal
                // state with the position flat (OnOrderUpdate) or the position
                // closes. A sneak fill here gets exits from OnExecutionUpdate
                // and is flattened on the next (still FlatNow) bar.
                if (pendingEntry) CancelOrder(entryOrder);
                if (inPosNow)
                {
                    if (Position.MarketPosition == MarketPosition.Long) ExitLong("EOD", SigLong);
                    else ExitShort("EOD", SigShort);
                }
            }
            else
            {
                // limit expired unfilled (pine 1124-1129): next bar opens past
                // deadline. Same cancel-race rule: plan survives the request.
                if (pendingEntry && !inPosNow && bar.AbsMin + 1 > pendingDeadline)
                    CancelOrder(entryOrder);

                // ---- new signal (pine 1050-1093)
                var s = o.Signal;
                if (s != null && LogSignals && !s.Taken)
                    Print($"{openEt:yyyy-MM-dd HH:mm} signal skipped ({s.SkipReason}) {(s.IsLong ? "long" : "short")} grade {s.Grade} bits {s.Bits}");
                if (s != null && s.Taken && entryOrder == null && !inPosNow)
                {
                    plan = BracketPlan.FromSignal(s);
                    planQty = s.Qty;
                    filledQty = 0;
                    exitFilledQty = 0;
                    pendingDeadline = s.DeadlineAbsMin;
                    double lim = Instrument.MasterInstrument.RoundToTickSize(s.Level);
                    Order ret = s.IsLong
                        ? EnterLongLimit(0, true, s.Qty, lim, SigLong)
                        : EnterShortLimit(0, true, s.Qty, lim, SigShort);
                    // Order updates can fire SYNCHRONOUSLY inside the submit
                    // call (NT8 advanced order handling) — OnOrderUpdate's
                    // name-capture is authoritative; only adopt the returned
                    // object when it is still alive, so a synchronous
                    // rejection cannot wedge a dead order into entryOrder.
                    if (ret != null && IsWorking(ret)) entryOrder = ret;
                    if (LogSignals)
                        Print($"{openEt:yyyy-MM-dd HH:mm} {(s.IsLong ? "LONG" : "SHORT")} {s.Qty} lim {s.Level} SL {s.Stop} TP {s.Tp2} (2R all-out, BE at +1R {s.Tp1}) grade {s.Grade} bits {s.Bits}");
                }

                // ---- exits (pine 1132-1134): (re)submit stop + 2R target at
                // every bar close while in position; a BE move re-prices the stop
                if (inPosNow && plan != null)
                    SubmitExits();
            }

            wasInPos = inPosNow;
            lastDayId = bar.DayId;
        }

        protected override void OnExecutionUpdate(Execution execution, string executionId,
            double price, int quantity, MarketPosition marketPosition, string orderId, DateTime time)
        {
            if (execution.Order == null) return;
            string name = execution.Order.Name;
            if (name == "TP" || name == "SL" || name == "EOD")
            {
                exitFilledQty += quantity;
                return;
            }
            if (name != SigLong && name != SigShort) return;
            // An entry EXECUTION is a fill no matter what the order's final
            // state says — NT8 documents Cancelled-with-Filled>0 for a limit
            // that filled while its cancel was pending. Never filter on state.
            filledQty += quantity;
            if (plan == null)
            {
                // defense-in-depth: an entry fill with no plan must never ride
                // unprotected — flatten immediately and say so loudly.
                Print("P1FastLucidEval25k: entry execution arrived with no active plan — emergency flatten.");
                if (marketPosition == MarketPosition.Long) ExitLong("EOD", SigLong);
                else if (marketPosition == MarketPosition.Short) ExitShort("EOD", SigShort);
                return;
            }
            if (eodActive)
            {
                // sneak fill during the 15:55/12:55 cancel: hard-flat NOW —
                // waiting for another bar could hold through the session break
                if (plan.IsLong) ExitLong("EOD", SigLong);
                else ExitShort("EOD", SigShort);
                return;
            }
            // attach protective exits the moment the entry fills so a live
            // position is never naked during the fill minute (the bar-close
            // resubmission in OnBarUpdate keeps them current afterwards)
            SubmitExits();
        }

        protected override void OnOrderUpdate(Order order, double limitPrice, double stopPrice,
            int quantity, int filled, double averageFillPrice, OrderState orderState,
            DateTime time, ErrorCode error, string comment)
        {
            // capture the entry order BY NAME — updates can arrive before the
            // submission call returns (NT8 advanced order handling)
            if (order.Name == SigLong || order.Name == SigShort)
            {
                if (orderState == OrderState.Cancelled || orderState == OrderState.Rejected
                    || orderState == OrderState.Filled)
                {
                    entryOrder = null;
                    // cancel-race resolution: discard the plan ONLY when the
                    // order itself reports zero filled quantity (order.Filled
                    // is authoritative here; our filledQty / Position can lag
                    // the execution callback) and we are flat.
                    if ((orderState == OrderState.Cancelled || orderState == OrderState.Rejected)
                        && order.Filled == 0 && filledQty == 0
                        && Position.MarketPosition == MarketPosition.Flat)
                        plan = null;
                }
                else
                    entryOrder = order;
            }
            if (orderState == OrderState.Rejected)
                Print($"P1FastLucidEval25k: ORDER REJECTED {order.Name} — check account/connection. {comment}");
        }

        private void SubmitExits()
        {
            if (plan == null) return;
            // remaining = entry fills minus exit fills (execution-accumulated,
            // per NT8 partial-fill guidance); Position.Quantity only as a
            // fallback when execution accounting is not available yet
            int qty = filledQty - exitFilledQty;
            if (qty <= 0) qty = Position.Quantity;
            if (qty <= 0) return;
            // NT8/exchange orders must sit on the tick grid; the method's
            // ATR-derived stop and 2R target are off-grid by construction.
            // Nearest-tick rounding here is the same divergence class the TV
            // referee run measured (median 0.3 ticks) and accepted.
            double stop = Instrument.MasterInstrument.RoundToTickSize(plan.StopPrice);
            double target = Instrument.MasterInstrument.RoundToTickSize(plan.TargetPrice);
            if (plan.IsLong)
            {
                ExitLongLimit(0, true, qty, target, "TP", SigLong);
                ExitLongStopMarket(0, true, qty, stop, "SL", SigLong);
            }
            else
            {
                ExitShortLimit(0, true, qty, target, "TP", SigShort);
                ExitShortStopMarket(0, true, qty, stop, "SL", SigShort);
            }
        }

        private static bool IsWorking(Order o) =>
            o.OrderState == OrderState.Working || o.OrderState == OrderState.Accepted
            || o.OrderState == OrderState.Submitted || o.OrderState == OrderState.ChangePending
            || o.OrderState == OrderState.ChangeSubmitted || o.OrderState == OrderState.PartFilled
            || o.OrderState == OrderState.CancelPending || o.OrderState == OrderState.CancelSubmitted
            || o.OrderState == OrderState.TriggerPending;

        private DateTime ToEastern(DateTime t)
        {
            if (easternTz == null) return t;
            var src = sourceTz ?? TimeZoneInfo.Local;
            if (src.Id == easternTz.Id) return t;
            return TimeZoneInfo.ConvertTime(DateTime.SpecifyKind(t, DateTimeKind.Unspecified), src, easternTz);
        }

        private long AbsMin(DateTime etWallClock) => etWallClock.Ticks / TimeSpan.TicksPerMinute;

        private static TimeZoneInfo FindTz(params string[] ids)
        {
            foreach (var id in ids)
                try { return TimeZoneInfo.FindSystemTimeZoneById(id); } catch { }
            return null;
        }

        #region Properties
        [NinjaScriptProperty, Range(25, double.MaxValue)]
        [Display(Name = "Risk $ per trade (1R)", GroupName = "1. Method", Order = 1,
            Description = "FAST METHOD = $800 nominal; the contract cap truncates tight-stop trades (that IS part of the method).")]
        public double RiskUsd { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "RSI(14) 5m gate", GroupName = "1. Method", Order = 2,
            Description = "NotOB (method): block longs when 5m RSI14 > 70 / shorts when < 30 on the breakout bar; direction is NOT consumed by a gated bar.")]
        public RsiGateMode RsiGate { get; set; }

        [NinjaScriptProperty, Range(0, 5)]
        [Display(Name = "Stop pad (x ATR 5m)", GroupName = "1. Method", Order = 3)]
        public double PadAtr { get; set; }

        [NinjaScriptProperty, Range(0, 100)]
        [Display(Name = "Daily stop (R, 0 = off)", GroupName = "1. Method", Order = 4,
            Description = "No NEW entries after realized day PnL <= -N x R. NOTE: the anchored engine reference book ran 0; the live preset ships 2.0.")]
        public double DailyStopR { get; set; }

        [NinjaScriptProperty, Range(0, 1000)]
        [Display(Name = "Max contracts cap (0 = uncapped)", GroupName = "1. Method", Order = 5)]
        public int CapQty { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "LIVE EVAL MODE (taper sizing)", GroupName = "2. Live eval sizing", Order = 1,
            Description = "ON for live eval trading only: sizes from the two inputs below (risk = min(needed / divisor, base, cap); DESPERADO full cap when room < base). OFF = engine-parity flat sizing — REQUIRED for any backtest/parity run. Update the two inputs after EVERY closed trade and EOD (disable/re-enable the strategy to change them).")]
        public bool LiveMode { get; set; }

        [NinjaScriptProperty, Range(0, double.MaxValue)]
        [Display(Name = "Profit still needed to pass ($)", GroupName = "2. Live eval sizing", Order = 2)]
        public double LiveNeeded { get; set; }

        [NinjaScriptProperty, Range(0, double.MaxValue)]
        [Display(Name = "Room to the floor ($)", GroupName = "2. Live eval sizing", Order = 3)]
        public double LiveRoom { get; set; }

        [NinjaScriptProperty, Range(1.0, 2.0)]
        [Display(Name = "Taper divisor", GroupName = "2. Live eval sizing", Order = 4)]
        public double LiveDiv { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "Trade window start (ET)", GroupName = "3. Window", Order = 1)]
        public DateTime TradeFrom { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "Trade window end (ET)", GroupName = "3. Window", Order = 2)]
        public DateTime TradeTo { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "Bars time zone id (blank = PC local)", GroupName = "4. Plumbing", Order = 1,
            Description = ".NET time zone id of the bar timestamps, i.e. the zone in NT8 Tools > Options > General. Leave blank when that setting matches your PC clock.")]
        public string BarsTimeZoneId { get; set; }

        [NinjaScriptProperty]
        [Display(Name = "Log signals to output", GroupName = "4. Plumbing", Order = 2)]
        public bool LogSignals { get; set; }
        #endregion
    }
}
