diff --git a/Nifty Multi Strategy Front Test - Master File.py b/Nifty Multi Strategy Front Test - Master File.py index f2795d5..3b40326 100644 --- a/Nifty Multi Strategy Front Test - Master File.py +++ b/Nifty Multi Strategy Front Test - Master File.py @@ -334,17 +334,30 @@ # DeprecationWarning onto the operator's console for every feed connection. # The SDK version is policy-pinned (DEPS-001 in requirements.txt), so until a # deliberate bump moves past that call we silence EXACTLY that message from -# EXACTLY that module: deprecation warnings raised by our own code, or by any +# EXACTLY those modules: deprecation warnings raised by our own code, or by any # other library, still reach the console. The warning fires per tick at # runtime (never at import), so installing the filter here -- at module load, -# long before any feed thread starts -- covers every code path; and because -# `filterwarnings` PREPENDS, it also wins over any blanket -W / -# PYTHONWARNINGS setting on the host. +# long before any feed thread starts -- covers every runtime code path. +# +# `dhanhq/__init__.py` imports BOTH `marketfeed` and `fulldepth`, and each ships +# the same `utc_time` helper (marketfeed.py:523, fulldepth.py:391). The runner +# only subscribes MarketFeed, so the fulldepth call site should never fire; it is +# covered anyway because the cost is one regex branch and the alternative is a +# surprise on the day something reaches for full-depth data. +# +# This filter does NOT cover pytest. Pytest wraps every test in +# `catch_warnings()` + `simplefilter("always")`, which RESETS `warnings.filters` +# and discards anything a module installed at import time; only pytest's own +# `-W` / `[tool.pytest.ini_options] filterwarnings` entries are re-applied +# inside that context. The matching ini entries live in `pyproject.toml` and +# `Tests/Dependencies/test_repository_policy.py` asserts the two stay in step -- +# without them this exact warning reappears in every pytest run even though the +# runner itself is silent. warnings.filterwarnings( "ignore", message=r"datetime\.datetime\.utcfromtimestamp\(\) is deprecated", category=DeprecationWarning, - module=r"dhanhq\.marketfeed", + module=r"dhanhq\.(marketfeed|fulldepth)", ) @@ -1891,6 +1904,8 @@ class MarketSnapshot: - `source_candle_ts` : timestamp of the latest candle in `frame`. - `candle_signature` : lightweight fingerprint of the latest row's state. - `fetched_at` : wall-clock time when the fetch completed. + - `official_candle_ts`: newest row supplied by the REST/official source, or + ``None`` when the publisher has not proved any official coverage. Why we keep both `source_candle_ts` and `candle_signature`: - During a live 1-minute candle, the timestamp does not change but the @@ -1906,6 +1921,10 @@ class MarketSnapshot: source_candle_ts: pd.Timestamp | None candle_signature: tuple | None fetched_at: datetime + # Websocket frames can contain a mix of official REST history and newer + # tick-built rows. This watermark tells a five-minute consumer exactly how + # far the official portion reaches without changing the canonical OHLC data. + official_candle_ts: pd.Timestamp | None = None PRICE_QUALITY_BROKER_FILL = "BROKER_FILL" @@ -2203,12 +2222,22 @@ def __init__(self) -> None: # ------------------------------------------------------------------ # OHLC pool # ------------------------------------------------------------------ - def update(self, timeframe: str, frame: pd.DataFrame) -> MarketSnapshot: + def update( + self, + timeframe: str, + frame: pd.DataFrame, + *, + official_candle_ts: pd.Timestamp | datetime | None = None, + ) -> MarketSnapshot: """ Atomically replace the stored snapshot for `timeframe`. The new snapshot is built BEFORE the lock is taken; only the swap happens under lock so the critical section stays small. + + ``official_candle_ts`` is optional because a pure tick publisher has no + official coverage to claim. REST-backed publishers pass their newest + source timestamp so conservative consumers can wait for official data. """ validated = validate_ohlc_frame(frame) source_candle_ts = pd.to_datetime(validated.iloc[-1]["timestamp"]) @@ -2220,6 +2249,11 @@ def update(self, timeframe: str, frame: pd.DataFrame) -> MarketSnapshot: source_candle_ts=source_candle_ts, candle_signature=candle_signature, fetched_at=datetime.now(ZoneInfo("Asia/Kolkata")), + official_candle_ts=( + None + if official_candle_ts is None + else pd.Timestamp(official_candle_ts) + ), ) with self._lock: self._snapshots[str(timeframe)] = snapshot @@ -2243,6 +2277,7 @@ def get(self, timeframe: str) -> MarketSnapshot | None: source_candle_ts=snapshot.source_candle_ts, candle_signature=snapshot.candle_signature, fetched_at=snapshot.fetched_at, + official_candle_ts=snapshot.official_candle_ts, ) # ------------------------------------------------------------------ @@ -3783,7 +3818,14 @@ def run(self) -> None: break try: frame = self.fetch_ohlc(timeframe) - snapshot = self.store.update(timeframe, frame) + # This producer's entire frame came directly from REST, so + # its newest timestamp is also the official-data watermark. + # Websocket mode publishes the same metadata after true-up. + snapshot = self.store.update( + timeframe, + frame, + official_candle_ts=pd.Timestamp(frame["timestamp"].max()), + ) if self.last_logged_candle_ts.get(timeframe) != snapshot.source_candle_ts: self.last_logged_candle_ts[timeframe] = snapshot.source_candle_ts self.log.info( @@ -3879,6 +3921,10 @@ def __init__( # Latest REST history: warmup seed, then refreshed by every true-up. # Supervisor-owned; the pump never touches it. self.official_frame: pd.DataFrame = pd.DataFrame() + # The frame may later be merged with newer tick-built rows. Keep the + # newest timestamp that came from REST as a separate watermark so a + # strategy can prove its completed bucket has been officially trued up. + self._official_candle_ts: pd.Timestamp | None = None # Connection state shared between pump and supervisor. self._feed_lock = threading.Lock() @@ -3974,8 +4020,13 @@ def _warmup_official_history(self) -> bool: exchange_segment=NIFTY_INDEX_EXCHANGE_SEGMENT, instrument_type=NIFTY_INDEX_INSTRUMENT_TYPE, ) - self.store.update("1", frame) self.official_frame = frame + self._official_candle_ts = pd.Timestamp(frame["timestamp"].max()) + self.store.update( + "1", + frame, + official_candle_ts=self._official_candle_ts, + ) self.log.info("Warmup history loaded | Rows=%s", len(frame)) return True except Exception as exc: @@ -4102,7 +4153,11 @@ def _publish_frame_locked(self, force: bool) -> None: if frame.empty: return try: - snapshot = self.store.update("1", frame) + snapshot = self.store.update( + "1", + frame, + official_candle_ts=self._official_candle_ts, + ) self._ohlc_ok = True if self.last_logged_candle_ts != snapshot.source_candle_ts: self.last_logged_candle_ts = snapshot.source_candle_ts @@ -4178,13 +4233,19 @@ def _run_true_up(self, reason: str, now_ist: datetime | None = None) -> None: self.aggregator.tick_bars_frame(), forming_minute=pd.Timestamp(now_ist).floor("min"), ) - self.official_frame = official - self._publish_frame_if_changed(force=True) + newest_official = pd.Timestamp(official["timestamp"].max()) + # Update the official frame, its watermark, and the published merged + # snapshot under one lock. Without this atomic boundary, the supervisor + # could publish new official OHLC with an older watermark (or vice versa) + # and make a waiting CPR worker observe a mixed generation. + with self._publish_lock: + self.official_frame = official + self._official_candle_ts = newest_official + self._publish_frame_locked(force=True) # Drop every tick bar the official history now covers. The merge would # ignore them anyway (official wins), but keeping them makes the NEXT # divergence report re-count this cycle's mismatches forever -- the # stats above must describe only the minutes trued-up right now. - newest_official = pd.Timestamp(official["timestamp"].max()) self.aggregator.prune_older_than(newest_official + pd.Timedelta(minutes=1)) log_fn = ( self.log.warning @@ -9519,6 +9580,7 @@ def __init__( self._latest_one_minute_frame = pd.DataFrame() self._latest_context_as_of: datetime | None = None self._last_agent_bar_identity: str | None = None + self._waiting_for_official_bar_identity: str | None = None self._prior_accepted_regime: str | None = None self._cpr_state: CPRAITradeState | None = None @@ -9590,6 +9652,48 @@ def _completed_bar_identity(self, strategy_frame: pd.DataFrame) -> str: timestamp = timestamp.tz_convert(IST_TIMEZONE).tz_localize(None) return f"{timestamp.date()}|{timestamp.isoformat()}" + @staticmethod + def _naive_ist_timestamp(value: object) -> pd.Timestamp | None: + """Normalize a timestamp before comparing producer and strategy clocks. + + Dhan history normally uses naive IST timestamps, while tests or future + adapters may provide timezone-aware values. Converting both shapes to + naive IST prevents a harmless representation difference from either + raising or incorrectly bypassing the official-data gate. + """ + + timestamp = pd.Timestamp(value) + if pd.isna(timestamp): + return None + if timestamp.tzinfo is not None: + timestamp = timestamp.tz_convert(IST_TIMEZONE).tz_localize(None) + return timestamp + + def _official_snapshot_covers_completed_bar( + self, + snapshot: MarketSnapshot, + strategy_frame: pd.DataFrame, + ) -> bool: + """Return true only when REST covers the bucket's final source minute. + + Five-minute candles are start-stamped. A 09:55 candle therefore needs + the official one-minute source to cover 09:59 before Codex may freeze + it. This condition-based gate naturally tolerates a slow Dhan response: + the normal worker poll simply checks the next atomic snapshot instead + of guessing how many seconds the REST true-up will take. + """ + + if strategy_frame.empty or "timestamp" not in strategy_frame.columns: + return False + official_timestamp = self._naive_ist_timestamp(snapshot.official_candle_ts) + bucket_start = self._naive_ist_timestamp( + strategy_frame.iloc[-1]["timestamp"] + ) + if official_timestamp is None or bucket_start is None: + return False + bucket_final_minute = bucket_start + pd.Timedelta(minutes=4) + return official_timestamp >= bucket_final_minute + def _position_state_payload(self) -> dict[str, object]: """Expose allowlisted premise/risk facts, never execution capabilities. @@ -10527,8 +10631,10 @@ def run(self) -> None: The worker starts decisions at 09:30 but safety runs even before then. Shared one-minute data is resampled with the current IST clock so forming - websocket minutes are excluded. Any single poll failure is logged and - retried; it does not terminate future hard-stop or square-off handling. + websocket minutes are excluded. A clock-complete bucket then waits until + the atomic REST watermark covers its fifth source minute. Any single poll + failure is logged and retried; it does not terminate future hard-stop or + square-off handling. """ self.log.info("Starting %s strategy worker.", self.strategy_name) @@ -10555,6 +10661,21 @@ def run(self) -> None: if completed.empty: self.wait_for_next_poll() continue + if not self._official_snapshot_covers_completed_bar( + snapshot, + completed, + ): + waiting_identity = self._completed_bar_identity(completed) + if waiting_identity != self._waiting_for_official_bar_identity: + self._waiting_for_official_bar_identity = waiting_identity + self.log.info( + "Completed CPR bucket %s is waiting for its final " + "one-minute REST true-up.", + waiting_identity, + ) + self.wait_for_next_poll() + continue + self._waiting_for_official_bar_identity = None self.process_strategy_frame(completed) except Exception as exc: # noqa: BLE001 - one turn must not kill safety self.log.exception("CPR AI worker poll failed: %s", exc) diff --git a/Signal Generators/CPR AI Agent/README.md b/Signal Generators/CPR AI Agent/README.md index 6ed1bcd..bc12487 100644 --- a/Signal Generators/CPR AI Agent/README.md +++ b/Signal Generators/CPR AI Agent/README.md @@ -28,8 +28,12 @@ deep-copy views of the same frozen completed-bar context: `HOLD`, premise `EXIT`, or the one permitted `SCALE_IN` request. The snapshot contains no order surface, account, credential, broker, venue, or -execution object. A missing or failed tool call, a duplicate/unapproved tool, -or any unexpected agent action invalidates the turn. +execution object. The immediate turn request repeats the four exact tool names +in addition to the developer prompt. If the first isolated turn still omits a +tool, or one tool reports failure, the host permits one retry against the same +immutable snapshot using only the time left in the original SDK deadline. A +second incomplete result, a duplicate/unapproved tool, or any unexpected agent +action invalidates the turn and produces `HOLD`. ## Decision contract and host gates @@ -74,8 +78,14 @@ host then enforces the selected framework: - It polls mechanical safety every five seconds and calls Codex at most once for each newly completed five-minute candle. A start-stamped one-minute candle is not complete until the next minute begins, and all five exact minute slots - must exist once; websocket revisions and later REST true-ups cannot create a - second call for the same bucket. + must exist once. +- In websocket mode, clock completeness alone is not enough. Every shared OHLC + snapshot carries an atomic `official_candle_ts` watermark. A 09:55 five-minute + bucket waits until the REST source covers its final 09:59 one-minute candle, + even when Dhan's true-up takes longer than its normal five-second delay. This + is a condition check rather than a hard-coded sleep. A later official revision + can still invalidate an in-flight result, but it cannot create a second model + call for an already-consumed bucket. - At 15:00 IST new entries and adds stop; management and exits continue. - At 15:15 IST the host square-off closes exposure and stops the worker. @@ -136,7 +146,8 @@ missing row with a warning; the labels remain separate from legacy CPR workers. With `CPR_AI_DECISION_LOGGING_ENABLED=true`, the host appends sanitized JSONL to `Backtest Outputs/cpr_ai_decisions.jsonl` by default. Each row records the frozen context, proposal, accepted regime, validation code/reason, authoritative host -geometry, execution outcome, latency, token usage, and tool-call evidence. +geometry, execution outcome, latency, inference-attempt count, aggregate token +usage across a retry, and final tool-call evidence. Credential-like mapping fields are removed recursively before serialization. Logging never makes a proposal executable, and an enabled log must succeed before an entry or add may be submitted. diff --git a/Signal Generators/CPR AI Agent/cpr_ai_agent.py b/Signal Generators/CPR AI Agent/cpr_ai_agent.py index cc50e56..57109a2 100644 --- a/Signal Generators/CPR AI Agent/cpr_ai_agent.py +++ b/Signal Generators/CPR AI Agent/cpr_ai_agent.py @@ -7,9 +7,10 @@ the strict schema, verifies model/prompt identity and freshness, and finally recalculates every executable field from deterministic facts. -Any missing tool call, stale bar, malformed response, optional SDK problem, or -contradictory market fact becomes ``HOLD``. Open-position mechanical safety -and order execution remain outside this module in the master worker. +Tool evidence that remains incomplete after one same-snapshot retry, a stale +bar, malformed response, optional SDK problem, or contradictory market fact +becomes ``HOLD``. Open-position mechanical safety and order execution remain +outside this module in the master worker. """ from __future__ import annotations @@ -23,6 +24,11 @@ from cpr_ai_tools import EXPECTED_TOOL_NAMES +# Missing/failed reads are safe to retry because all four tools are read-only +# views of the exact same immutable snapshot. Capability violations are never +# retried: they indicate that the turn left the allowlisted contract. +_RETRIABLE_TOOL_EVIDENCE_CODES = frozenset({"missing_tool_call", "failed_tool_call"}) + @dataclass(frozen=True) class CPRToolCallRecord: @@ -76,6 +82,7 @@ class CPRAgentOutcome: latency_ms: int = 0 token_usage: dict[str, int] = field(default_factory=dict) tool_evidence: tuple[CPRToolCallRecord, ...] = () + inference_attempts: int = 1 def _hold(code: str, reason: str, proposal: Any | None = None, *, regime: str | None = None) -> CPRAgentOutcome: @@ -418,10 +425,12 @@ def decide( ) -> CPRAgentOutcome: """Run one turn and return only contemporaneous host-owned permission. - A bar signature is consumed before inference starts, so failures and - timeouts are not retried on the same market bar. ``current_signature`` - lets the host suppress a result when fresher completed evidence arrived - while Codex was thinking. + A bar signature is consumed before inference starts, so a failed host + decision is never started again by the next worker poll. Inside this + single decision only, incomplete read-only tool evidence may receive one + retry against the same frozen snapshot and total deadline. + ``current_signature`` lets the host suppress a result when fresher + completed evidence arrived while Codex was thinking. """ with self._lock: @@ -434,9 +443,15 @@ def decide( executor = ThreadPoolExecutor(max_workers=1) release_when_finished = False try: - future = executor.submit(self._run_turn, context, bar_signature) + future = executor.submit( + self._run_turn_with_tool_retry, + context, + bar_signature, + ) try: - result = future.result(timeout=self.timeout_seconds) + result, inference_attempts, combined_usage = future.result( + timeout=self.timeout_seconds + ) except TimeoutError: # Python cannot safely kill an already running SDK call. Do # not wait for it during shutdown and never retain its future, @@ -454,11 +469,82 @@ def decide( latency_ms = int((monotonic() - started) * 1000) outcome = self._validate_run(result, context, bar_signature, current_signature) outcome.latency_ms = latency_ms - outcome.token_usage = dict(result.token_usage) + outcome.token_usage = combined_usage outcome.tool_evidence = result.tool_calls + outcome.inference_attempts = inference_attempts return outcome - def _run_turn(self, context: Mapping[str, Any], bar_signature: str) -> CPRAgentRunResult: + def _run_turn_with_tool_retry( + self, + context: Mapping[str, Any], + bar_signature: str, + ) -> tuple[CPRAgentRunResult, int, dict[str, int]]: + """Retry incomplete frozen-tool evidence once inside one total deadline. + + Both attempts receive the same in-memory context and bar signature. A + retry therefore cannot silently move to newer market facts. The second + attempt receives only the wall-clock budget left after the first one, + so this recovery path never doubles the configured SDK timeout. + """ + + deadline = monotonic() + self.timeout_seconds + results: list[CPRAgentRunResult] = [] + for attempt_index in range(2): + if attempt_index == 0: + # Preserve the configured value exactly on the normal path. It + # is useful operator evidence and keeps existing runner behavior + # unchanged when no retry is required. + attempt_timeout = self.timeout_seconds + else: + attempt_timeout = deadline - monotonic() + if attempt_timeout <= 0: + break + result = self._run_turn( + context, + bar_signature, + timeout_seconds=attempt_timeout, + ) + results.append(result) + evidence_error = self._tool_evidence_error(result) + if ( + evidence_error is None + or evidence_error[0] not in _RETRIABLE_TOOL_EVIDENCE_CODES + ): + break + + # The first call either produced a result or raised to ``decide()``, so + # this list cannot be empty. Keeping the assertion documents that local + # invariant without converting an SDK exception into trusted evidence. + assert results + return results[-1], len(results), self._combined_token_usage(results) + + @staticmethod + def _combined_token_usage( + results: list[CPRAgentRunResult], + ) -> dict[str, int]: + """Aggregate billed counters while retaining one context-window size. + + A retry is a second model turn and its tokens must remain visible in the + JSONL audit. ``model_context_window`` describes capacity rather than + consumption, so it uses the largest reported value instead of a sum. + """ + + combined: dict[str, int] = {} + for result in results: + for key, value in result.token_usage.items(): + if key == "model_context_window": + combined[key] = max(combined.get(key, 0), int(value)) + else: + combined[key] = combined.get(key, 0) + int(value) + return combined + + def _run_turn( + self, + context: Mapping[str, Any], + bar_signature: str, + *, + timeout_seconds: float | None = None, + ) -> CPRAgentRunResult: """Build prompt/schema lazily and pass only advisory inputs to the child. The bar signature is cadence metadata; the frozen context is the only @@ -480,7 +566,14 @@ def _run_turn(self, context: Mapping[str, Any], bar_signature: str) -> CPRAgentR reasoning_effort=self.reasoning_effort, prompt_version=self.prompt_version or CPR_AI_PROMPT_VERSION, output_schema=CPRAgentDecision.model_json_schema(), - timeout_seconds=self.timeout_seconds, + # Direct diagnostic callers historically used this helper without + # an explicit deadline. Normal and retry paths pass their exact + # per-attempt budget; the fallback preserves that diagnostic API. + timeout_seconds=( + self.timeout_seconds + if timeout_seconds is None + else timeout_seconds + ), ) def _validate_run( diff --git a/Signal Generators/CPR AI Agent/cpr_ai_codex_subprocess.py b/Signal Generators/CPR AI Agent/cpr_ai_codex_subprocess.py index b8a7903..ae4cd1d 100644 --- a/Signal Generators/CPR AI Agent/cpr_ai_codex_subprocess.py +++ b/Signal Generators/CPR AI Agent/cpr_ai_codex_subprocess.py @@ -21,10 +21,15 @@ _REQUEST_KEYS = {"snapshot_path", "model", "reasoning_effort", "prompt", "output_schema"} _EXPECTED_TOOLS = ("session_levels", "momentum_vwap", "market_structure", "position_state") -# ``build_system_prompt()`` contains durable role, tool, and safety policy. The -# actual turn request stays short so those rules are stated once at the SDK's -# stronger developer-instruction layer instead of being repeated as user text. -_TURN_REQUEST = "Evaluate the current frozen CPR context and return one decision." +# ``build_system_prompt()`` remains the durable policy authority. Repeating the +# four reads in the immediate turn request is intentional defense in depth: a +# production turn occasionally returned structured HOLD text without consulting +# MCP even though the developer prompt said those calls were mandatory. +_TURN_REQUEST = ( + "Before deciding, call each frozen MCP tool exactly once: session_levels, " + "momentum_vwap, market_structure, and position_state. Wait for all four " + "calls to complete, then evaluate the frozen CPR context and return one decision." +) _ALLOWED_TURN_ITEM_TYPES = frozenset( { # The SDK records the prompt submitted through ``Thread.run`` as a diff --git a/Signal Generators/CPR AI Agent/cpr_ai_decision_log.py b/Signal Generators/CPR AI Agent/cpr_ai_decision_log.py index 3c00966..1b46dc1 100644 --- a/Signal Generators/CPR AI Agent/cpr_ai_decision_log.py +++ b/Signal Generators/CPR AI Agent/cpr_ai_decision_log.py @@ -161,6 +161,11 @@ def write( }, "execution": execution or {"mode": "ORDER_FREE", "submitted": False}, "latency_ms": latency_ms, + # A value of two means the first isolated turn did not provide + # complete frozen-tool evidence and the host used its one safe, + # same-snapshot retry. Recording it keeps latency and token + # totals understandable during later operational review. + "inference_attempts": int(getattr(outcome, "inference_attempts", 1)), "token_usage": token_usage, "tool_evidence": tool_evidence, } diff --git a/Signal Generators/SL Hunting AI Agent/premarket_note.json b/Signal Generators/SL Hunting AI Agent/premarket_note.json index 8e6913e..2fa6065 100644 --- a/Signal Generators/SL Hunting AI Agent/premarket_note.json +++ b/Signal Generators/SL Hunting AI Agent/premarket_note.json @@ -1,31 +1,31 @@ { - "for_date": "2026-08-12", - "source": "Intraday Hunter, 'Prediction For 12 AUG 2026' (CoxS77NfnsI, uploaded 2026-08-11)", - "context": "NIFTY and Sensex sold hard but produced no follow-through and never crossed the round number, so few carried shorts overnight. BankNIFTY sold then recovered, hitting the stops of sellers who joined on the retracement.", + "for_date": "2026-08-13", + "source": "Intraday Hunter, 'Prediction For 13 AUG 2026' (PfthlsdW2E8, uploaded 2026-08-12)", + "context": "Sensex sold hard, then took support EXACTLY at the round number and rallied, chasing out everyone who was short. BankNIFTY has been positive from the start. NIFTY sold but is recovering. BUYERS, not sellers, are now the seated crowd.", "plan": [ - "FLAT to GAP-DOWN: identify SELL-side setups. He states this separately for all three indices -- NIFTY, BankNIFTY and Sensex each get the same conditional.", - "A MODERATE gap-up keeps broadly the same plan; he says 'same plan' for Sensex and names the sell side for BankNIFTY in the same breath as the gap-up case.", - "A LARGE gap-up VOIDS the plan: 'if a big gap-up opens, maybe the market has just made a TRAP -- in a big gap-up we cannot make such a plan for now.' Stand aside; do not guess the missing branch.", - "The seller crowd is SPENT, not available to hunt. On BankNIFTY: sellers who entered on the retracement had their stops hit by the recovery, so the market has already taken them out.", - "Nobody carried size short overnight -- the move never crossed the round number and produced no follow-through, so overnight inventory is thin on BOTH sides.", - "AMBIGUITY, recorded rather than resolved: on NIFTY he also says a mild gap-up can be followed WITH the market, 'if not many sellers are seated the market may not find SLs.' That cuts against the sell-side line; do not force a reading.", - "11 Aug was expiry and gave one move then nothing. Today is an ordinary Wednesday session, so expiry distortion is not a factor." + "FLAT to GAP-DOWN: identify SELL-side setups. Stated for all three indices, and on BankNIFTY the reason is explicit -- target the BUYERS who are now seated.", + "GAP-UP above the round number: go WITH the market and identify BUY-side setups. Do NOT try to hunt the buyers there.", + "The reason is WHERE the stops sit: 'buyers' SLs should be BELOW the round number.' A gap-up above it leaves their stops far away, so they are not huntable and following beats fading.", + "The seller crowd is SPENT. Sensex took support at the round number and the recovery chased shorts out on the retracement -- the same spent-seller condition as 12 Aug.", + "BankNIFTY is the index where buyers are genuinely seated: positive from the open, a retracement, then more upside. That is the crowd a gap-down would hunt.", + "SENSEX EXPIRY tomorrow, flagged explicitly. Expect expiry pinning and premium distortion on the Sensex read specifically.", + "NIFTY is the least committed of the three: selling, but 'some recovery is also visible'. Treat its conditional as weaker than BankNIFTY's." ], "levels": [ { "index": "NIFTY", - "resistance": [24560, 24610], - "support": [24430, 24345] + "resistance": [24500, 24600], + "support": [24260] }, { "index": "BANKNIFTY", - "resistance": [57650, 57800], - "support": [57100, 56960] + "resistance": [58000, 58300], + "support": [57500, 57310] }, { "index": "SENSEX", - "resistance": [78475, 78640], - "support": [78046, 77810] + "resistance": [78145, 78500], + "support": [77500, 77200] } ] } diff --git a/Signal Generators/SL Hunting AI Agent/sl_hunting_doc.md b/Signal Generators/SL Hunting AI Agent/sl_hunting_doc.md index 4c73d5d..5fdb21d 100644 --- a/Signal Generators/SL Hunting AI Agent/sl_hunting_doc.md +++ b/Signal Generators/SL Hunting AI Agent/sl_hunting_doc.md @@ -3085,3 +3085,180 @@ Test updated: `test_shipped_note_matches_august_12_intraday_hunter_plan` replaces the 11 Aug equivalent. It asserts the escape hatch and the ambiguity line survive verbatim, because those are the two things a copy-forward or a tidy-up would silently remove. + +--- + +## Video addendum - the 12 Aug LIVE SESSION (v4f) + +**Source:** Intraday Hunter live session, 12 Aug 2026 (`CV_Fs3TFF5I`, 6:46, +published 10:29 IST). A **WIN**, taken from the *same* opening condition that +produced the 11 Aug loss. That pairing is the whole value of this addendum. + +### The same empty book, two opposite outcomes + +Both sessions opened flat, sold off immediately, and had IH saying — in almost +identical words — that nobody was positioned: + +| | 11 Aug (v4e, LOSS) | 12 Aug (v4f, WIN) | +|---|---|---| +| Opening read | flat, sharp sell-off | flat, sharp sell-off | +| Book | "neither the BUYER's SLs nor the SELLER's" | "neither many buyers nor many sellers" | +| What he did | **predicted** who would arrive, entered on that | **waited** for the recovery to actually begin | +| Result | cut at his last point | booked a good target | + +So an empty book is not simply a no-trade condition, as v4e recorded it. It is a +statement that the market **must manufacture a trap**, because it has nothing +else to work with. What it does not tell you is which side the trap points at — +and the difference between the two days is whether he guessed that or waited to +be shown: + +> "Here there are neither many buyers nor many sellers... so some kind of TRAP +> will definitely form here. We were waiting for exactly that." + +The confirmation he waited for was concrete: a sharp recovery off a drop that had +just trapped the sellers who chased it, led by one index. *"The trap somewhere +seemed to have been made FOR THE SELLERS."* + +### The new idea: a repeated chart is a trap, not a trend + +The reason he expected a trap at all is the sameness of the two days: + +> "Normally the market does NOT repeat the chart." +> "Yesterday it opened flat and directly started falling. Here too flat open, +> directly fell... so some kind of trap will definitely form." + +A shape everyone watched yesterday is a shape everyone is ready for today, and a +move nobody has to be tricked into paying for is not a move the market needs to +make. Note this is distinct from v4a's SECOND-DAY RECRUITMENT, which is about a +crowd built across two days and then hunted; this is about the **path** being +identical, which is what makes the copy a trap. + +### The move that denied him entry + +He wanted to sell — the pre-open note said sell-side — and never got in: + +> "If it had gone a bit slow, or given us a slight up move first, we would have +> had a chance to sell... but the momentum was very sharp. **Everything happened +> in ONE MINUTE.**" + +That is v4d's gap logic at intraday scale: a move that completes before anyone can +join it recruits nobody, creates no inventory, and leaves nothing behind it to +hunt. The correct response is not to chase it but to ask what the market must do +next to trap somebody. + +### Exit: the profit stopped growing + +He held while it paid — *"momentum is very fast, it will not stop easily, the +target may be BIG"* — and closed on a change in rate, not in price: + +> "Now see, the profit has started REDUCING. So let us book. The more smoothly +> the profit comes, the better." +> "Especially if we have ALREADY SEEN a good target, after that we should not be +> greedy... we had already captured one momentum." + +### Knowledge changes (v4f, all prose) + +- `OPENING_DRIVE`: THE CHART DOES NOT REPEAT TWO DAYS RUNNING; A MOVE THAT DENIED + YOU ENTRY WAS NOT YOUR MOVE; AN EMPTY BOOK MEANS A TRAP IS COMING — WAIT FOR IT + TO REVEAL ITS DIRECTION; THE SHARPEST RECOVERY NAMES THE LEADING INDEX, AND + SIZE FOLLOWS IT. +- `RISK`: BOOK WHEN THE PROFIT STOPS GROWING, NOT WHEN IT REVERSES. +- Test markers: `test_system_prompt_has_v4f_repeat_chart_and_confirmation_knowledge`, + plus two drift guards. `test_v4f_confirmation_rule_does_not_reopen_the_v4e_forecasting_hole` + is the important one: v4e and v4f are a matched pair, and v4f read alone would + license exactly the forecast v4e forbids, so the test asserts the confirmation + requirement and v4e's HOLD both survive together. +- Prompt size 101,087 -> 105,933 chars (headroom 14,067). + +### How our agent traded the same session + +**Provisional — the runner was still live at 14:15** (market closes 15:30). + +| Strategy | Legs | Realized | +|---|---|---| +| SMA Crossover | 1 | +8,911.50 | +| Renko | 3 | +5,427.50 | +| Donchian Bearish | 1 | +3,406.00 | +| CPR Algo 3 | 2 | +3,272.75 | +| Regime Adaptive | 2 | +575.25 | +| Long Strangle | 10 | +565.50 | +| Bollinger Bands | 1 | +481.00 | +| EMA | 2 | -331.50 | +| Heikin Ashi | 9 | -728.00 | +| SL Hunting AI | 2 | -1,034.25 | +| Parabolic SAR | 2 | -1,056.25 | +| RSI Reversal | 1 | -1,160.25 | +| Supertrend Bullish | 1 | -2,093.00 | +| Mean Reversion Z-Score | 2 | -2,717.00 | +| **Total** | **39** | **+13,519.25** | + +SL Hunting cross-checks exactly against its own `Result summary` (-1,034.25, +Trades=1). + +**The agent took one trade and it was the wrong side.** At 10:30 it entered SHORT +on `double_top_shooting_star_reversal` — one minute after IH had gone long on the +recovery — and cut at 10:31 on `per_leg_index_hierarchy_cut`. So the index +hierarchy rule (v3y) did its job and stopped a bad trade inside sixty seconds; +the entry itself was the error. + +That entry is precisely what v4f is meant to prevent. The market had opened flat, +sold off in one minute, and begun recovering sharply — a repeated chart, a move +that denied entry, and an empty book. v4f says all three of those argue against +selling the drop and for waiting to see which way the manufactured trap points. +The agent instead read the drop as a continuation and shorted into the recovery. + +Encouragingly, the deterministic strategies had a strong day on the same tape: +SMA Crossover alone made more than the whole basket lost, and only four of +fourteen finished negative. + +--- + +### Pre-open note for 2026-08-13 (Thursday, SENSEX expiry) + +**Source:** Intraday Hunter, "Prediction For 13 AUG 2026" (`PfthlsdW2E8`, +uploaded 2026-08-12, 2:13). Note-only; no knowledge version attached. + +**The seated crowd has flipped to BUYERS.** Yesterday's note described a spent +seller crowd and thin positioning on both sides. Today the sellers are spent for +a specific, observable reason, and buyers have taken their place: + +> "Good selling came, but the market took support EXACTLY at the round number +> and gave a positive momentum. So those sitting short would have been chased +> out on the retracement — it has already chased the sellers out." + +BankNIFTY is where the buyers actually are: *"this chart has been positive from +the start... a retracement and then overall positive momentum."* That is the +crowd a gap-down would hunt. + +**The gap-up branch is a FOLLOW, and the reason is stop LOCATION.** This is the +most transferable part of the note, and it is a sharper statement of the +round-number idea than the series has had: + +> "Buyers' SLs should be BELOW the round number. If the market is above the +> round number, the buyers are not going to give their SLs — so we go WITH the +> market." + +So the conditional is not symmetric guesswork. A gap-down puts price *into* the +buyers' stop zone and makes them huntable; a gap-up above the round number puts +price *away* from it, leaves nothing to hunt, and the correct move is to follow. +That pairs directly with v4c's ROUND NUMBERS AMPLIFY RECRUITMENT and with v4f's +AN EMPTY BOOK MEANS A TRAP IS COMING — here the book is not empty, so the read is +about geometry rather than about waiting for confirmation. + +**Sensex expiry** tomorrow, flagged explicitly, so the Sensex leg carries the +usual pinning and premium distortion. NIFTY is the weakest of the three reads — +*"selling was seen but some recovery is also visible"* — and the note says so +rather than granting it the same confidence as BankNIFTY. + +**Transcription caveats, two this time.** NIFTY's resistance pair arrived as +"246 and 24500", read as 24600/24500 (the dropped-trailing-zero artefact seen on +4, 7, 10 and 12 Aug). NIFTY's SECOND support arrived as "2476" and could not be +resolved to a plausible level at all, so it is **omitted** rather than guessed — +a missing advisory level is safer than a wrong one, and the test asserts NIFTY +carries exactly one support so a later "tidy-up" cannot invent the second. +Sensex's 78145 resistance is recorded as heard. + +Test updated: `test_shipped_note_matches_august_13_intraday_hunter_plan` +replaces the 12 Aug equivalent and asserts both branch directions plus the +round-number stop-location reasoning, because an inverted plan and a smoothed- +away justification are the two failures a copy-forward would produce. diff --git a/Signal Generators/SL Hunting AI Agent/sl_hunting_knowledge.py b/Signal Generators/SL Hunting AI Agent/sl_hunting_knowledge.py index c3e55bb..24f8d1d 100644 --- a/Signal Generators/SL Hunting AI Agent/sl_hunting_knowledge.py +++ b/Signal Generators/SL Hunting AI Agent/sl_hunting_knowledge.py @@ -573,6 +573,53 @@ nothing more: on the very session that produced it, the sharp slide was followed by continuous selling and the bait read was WRONG. Use it to break a tie between two otherwise-equal reads, never as the premise of a trade on its own. +- THE CHART DOES NOT REPEAT TWO DAYS RUNNING (v4f). When today's open reproduces + yesterday's shape — same flat open, same immediate drop, same indices — that + SAMENESS is itself the tell, and it argues AGAINST the continuation everyone + else is taking. IH, watching an exact repeat of the prior session: "normally the + market does not repeat the chart... if it makes the same chart today" then "some + kind of TRAP will definitely form here. We were waiting for exactly that." + The mechanism is participation again: a shape everyone watched yesterday is a + shape everyone is ready for today, and a move nobody has to be tricked into + paying for is not a move the market needs to make. So a second-day carbon copy + raises the probability of a REVERSAL against the copied direction, not of a + continuation along it. Note the asymmetry with SECOND-DAY RECRUITMENT (v4a): + that rule is about a crowd built over two days and then hunted; this one is + about the PATH being identical, which is what makes the second day a trap. +- A MOVE THAT DENIED YOU ENTRY WAS NOT YOUR MOVE (v4f). The clean intraday form of + v4d's gap logic. IH wanted to sell and never got the chance: "if it had gone a + bit slow, or given us a slight up move first, we would have had a chance to + sell... but the momentum was very sharp — everything happened in ONE MINUTE." + A move that completes before anyone can join it has recruited nobody, so it has + created no inventory and there is nothing behind it to hunt. Practical rule: if + the move you wanted is already over, do NOT chase it late and do NOT assume it + continues. Ask instead what the market must do next to trap somebody, because a + one-minute move leaves it with the same empty book it started with. +- AN EMPTY BOOK MEANS A TRAP IS COMING — WAIT FOR IT TO REVEAL ITS DIRECTION (v4f). + This is the reconciliation of v4e's most expensive rule, and the two sessions + that produced them are worth holding side by side. BOTH days opened with IH + saying nobody was seated: "here there are neither many buyers nor many sellers." + On 11 Aug he PREDICTED who would arrive, entered on that forecast, and lost. On + 12 Aug he waited for the market to SHOW him, entered only once a sharp recovery + had actually begun, and won. + So an empty book is not merely a no-trade condition (v4e) — it is a statement + that the market MUST manufacture a trap, because it has nothing else to work + with. What it does not tell you is which side the trap is aimed at. The rule is + therefore: on an empty book, form the hypothesis but wait for + CONFIRMATION IN PRICE before acting. IH's confirmation was explicit — a sharp + recovery, led by one index, off a drop that had trapped the sellers who chased + it: "the trap somewhere seemed to have been made FOR THE SELLERS." Waiting cost + him the first part of the move and still produced the day's profit; forecasting + cost him the whole of the previous session. +- THE SHARPEST RECOVERY NAMES THE LEADING INDEX, AND SIZE FOLLOWS IT (v4f). When + the three indices turn together but at different speeds, the fastest one is not + merely confirming — it is where the move is actually being made, and it should + carry the most size. IH: "NIFTY and Sensex recovery is not as visible, but + BankNIFTY was recovering very SHARPLY... and if we need more quantity in + BankNIFTY, the benefit comes from there." He also used the laggards as the + target case rather than the entry case: "gradually Sensex and NIFTY will try to + cover themselves, so we will get our target." Read alongside INDEX HIERARCHY: + the hierarchy decides who must AGREE, this decides who to WEIGHT. - SEATED-BUYER TEST — run this BEFORE the long branch fires (v3y). The whole gap-up-long premise is "a gap-up leaves nobody trapped, so there is no hunt available". That premise is FALSE when the prior session already seated a buying @@ -997,6 +1044,23 @@ side ONLY. Applying them to a loser is not patience, it is the premise being re-argued after the evidence arrived. Never widen, delay, or suspend an exit rule because the reasoning still feels right. +- BOOK WHEN THE PROFIT STOPS GROWING, NOT WHEN IT REVERSES (v4f). The exit trigger + is the RATE at which the position is still gaining, not a price level and not a + loss. IH held while the move was paying — "momentum is very fast, it will not + stop easily, so the target may be BIG... we are not exiting now" — and closed + the moment that changed: "now see, the profit has started REDUCING. So let us + book. The more smoothly the profit comes, the better." + Two supports he gives for it, both worth keeping: + * "Especially if we have ALREADY SEEN a good target, after that we should not + be greedy." A target that has been printed on the screen counts as reached, + even if you did not take it there. + * "We had already captured one momentum... if any retracement becomes a bit + too big it becomes a problem for us." One captured leg is a complete trade; + the second leg is a new trade needing its own premise, not a continuation + of this one's entitlement. + This is the general form of BOOK BEFORE THE ROUND NUMBER (v4d): that rule names + WHERE the late crowd's targets sit, this one names WHEN your own edge has been + spent regardless of where price is. - CROWD SIZE IS THE THIRD TARGET INPUT (v4c). Alongside how recently the crowd was recruited (v4a) and whether it has averaged down (v4b), HOW MANY are seated scales the move available against them — and for a reason worth knowing: a diff --git a/Tests/Signal Generators/CPR AI Agent/test_cpr_ai_runtime.py b/Tests/Signal Generators/CPR AI Agent/test_cpr_ai_runtime.py index fb0b5b9..a4ef6e4 100644 --- a/Tests/Signal Generators/CPR AI Agent/test_cpr_ai_runtime.py +++ b/Tests/Signal Generators/CPR AI Agent/test_cpr_ai_runtime.py @@ -162,6 +162,51 @@ def test_agent_rejects_incomplete_or_unexpected_four_tool_evidence(missing, fail assert outcome.action == "HOLD" +@pytest.mark.parametrize( + "first_attempt_calls", + [ + (), + _calls(failed="position_state"), + ], + ids=["missing-all-tools", "failed-one-tool"], +) +def test_agent_retries_one_incomplete_frozen_tool_attempt_within_the_same_deadline(first_attempt_calls): + """A transient MCP-evidence miss gets one same-snapshot retry, never authority. + + The first local result models the two production failure shapes: Codex may + skip every MCP read, or one MCP read may fail. The second result is valid. + A regression that removes the retry, changes the frozen bar between + attempts, or resets the configured deadline will fail this test. + """ + + attempts = [] + + def runner(**kwargs): + attempts.append(kwargs) + calls = first_attempt_calls if len(attempts) == 1 else _calls() + return CPRAgentRunResult( + final_response=_proposal("HOLD", "UNDECIDED", "NONE").model_dump_json(), + tool_calls=tuple(calls), + token_usage={"total_tokens": 10 + len(attempts)}, + ) + + frozen = _context() + outcome = CPRAgent(runner=runner, timeout_seconds=17.5).decide( + frozen, + bar_signature="same-frozen-bar", + ) + + assert outcome.validation_code == "accepted_hold" + assert outcome.inference_attempts == 2 + assert outcome.token_usage == {"total_tokens": 23} + assert len(attempts) == 2 + assert attempts[0]["context"] is frozen + assert attempts[1]["context"] is frozen + assert attempts[0]["bar_signature"] == "same-frozen-bar" + assert attempts[1]["bar_signature"] == "same-frozen-bar" + assert 0 < attempts[1]["timeout_seconds"] <= attempts[0]["timeout_seconds"] <= 17.5 + + def test_runtime_configuration_is_read_only_and_sanitizes_credentials(tmp_path): """A child gets no execution surface or trading/API credentials.""" @@ -567,6 +612,7 @@ def test_decision_log_and_order_free_smokes_keep_only_sanitized_host_evidence(tm row = json.loads(raw) assert "secret" not in raw assert row["validation"]["code"] == "accepted_hold" + assert row["inference_attempts"] == 1 assert row["frozen_context"]["session_levels"]["next_levels"]["ordered"] == [ {"name": "r1", "price": 110.0} ] @@ -763,7 +809,11 @@ def thread_start(self, **kwargs): assert observed["start"]["config"] == codex_child.build_isolated_thread_config(str(tmp_path / "snapshot.json")) assert observed["start"]["approval_mode"] == "deny" assert observed["start"]["developer_instructions"] == "prompt" - assert observed["run"][0] == "Evaluate the current frozen CPR context and return one decision." + assert observed["run"][0] == ( + "Before deciding, call each frozen MCP tool exactly once: session_levels, " + "momentum_vwap, market_structure, and position_state. Wait for all four " + "calls to complete, then evaluate the frozen CPR context and return one decision." + ) assert observed["run"][1] == {"approval_mode": "deny", "output_schema": {"type": "object"}, "effort": "medium"} assert [call["tool"] for call in response["tool_calls"]] == list(EXPECTED_TOOL_NAMES) assert [call["status"] for call in response["tool_calls"]] == ["completed"] * 4 diff --git a/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_premarket.py b/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_premarket.py index d767a69..5ff2eb7 100644 --- a/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_premarket.py +++ b/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_premarket.py @@ -201,18 +201,16 @@ def test_shipped_note_targets_the_next_TRADING_day_not_the_next_calendar_day(): ) -def test_shipped_note_matches_august_12_intraday_hunter_plan(): - """The committed advisory must match the hand-checked 11 Aug transcript. +def test_shipped_note_matches_august_13_intraday_hunter_plan(): + """The committed advisory must match the hand-checked 12 Aug transcript. This catches a stale prior-session note, an inverted gap plan, or a mistyped chart level before the dated note is injected into the live prompt. - Two things make this one distinctive. The seller crowd is described as - already SPENT -- BankNIFTY's recovery hit the stops of sellers who joined on - the retracement -- so there is little left to hunt on that side. And it - carries the series' second explicit ESCAPE HATCH: a large gap-up voids the - plan outright, which the note records as stand-aside rather than inventing - the branch he does not state. + What distinguishes this one is that the seated crowd has FLIPPED to buyers, + and the gap-up branch is a follow rather than a fade -- justified by where + the stops sit relative to the round number. Both directions are asserted + explicitly because a copy-forward would silently keep the previous day's. """ import os @@ -221,33 +219,35 @@ def test_shipped_note_matches_august_12_intraday_hunter_plan(): note = load_premarket_note(shipped) assert note is not None - assert note.for_date == "2026-08-12" - assert "CoxS77NfnsI" in note.source - # The distinguishing facts: no follow-through, and no overnight short carry. - assert "never crossed the round number" in note.context + assert note.for_date == "2026-08-13" + assert "PfthlsdW2E8" in note.source + # The distinguishing fact: buyers, not sellers, are the seated crowd now. + assert "BUYERS, not sellers, are now the seated crowd" in note.context assert note.plan[0].startswith("FLAT to GAP-DOWN: identify SELL-side setups") - # The escape hatch must survive verbatim -- it is the one branch he refuses - # to specify, and inventing it is exactly the failure this test guards. - assert any("LARGE gap-up VOIDS the plan" in line for line in note.plan) - assert any("Stand aside" in line for line in note.plan) - # The ambiguity is recorded, not smoothed away. - assert any("AMBIGUITY, recorded rather than resolved" in line for line in note.plan) - assert any("SPENT, not available to hunt" in line for line in note.plan) + assert note.plan[1].startswith("GAP-UP above the round number") + # The round-number stop-location reasoning is the whole justification for + # following a gap-up instead of fading it; it must survive verbatim. + assert any("BELOW the round number" in line for line in note.plan) + assert any("not huntable" in line for line in note.plan) + assert any("SENSEX EXPIRY" in line for line in note.plan) assert len(note.plan) == 7 assert [level.model_dump() for level in note.levels] == [ { "index": "NIFTY", - "resistance": [24560.0, 24610.0], - "support": [24430.0, 24345.0], + # Only ONE support: the transcript's second NIFTY support arrived as + # "2476" and could not be resolved, so it is omitted rather than + # guessed. A missing advisory level is safer than a wrong one. + "resistance": [24500.0, 24600.0], + "support": [24260.0], }, { "index": "BANKNIFTY", - "resistance": [57650.0, 57800.0], - "support": [57100.0, 56960.0], + "resistance": [58000.0, 58300.0], + "support": [57500.0, 57310.0], }, { "index": "SENSEX", - "resistance": [78475.0, 78640.0], - "support": [78046.0, 77810.0], + "resistance": [78145.0, 78500.0], + "support": [77500.0, 77200.0], }, ] diff --git a/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_schema.py b/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_schema.py index 1e0b26e..9e6ea14 100644 --- a/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_schema.py +++ b/Tests/Signal Generators/SL Hunting AI Agent/test_sl_hunting_schema.py @@ -908,6 +908,61 @@ def test_v4e_empty_book_is_a_no_trade_not_a_forecasting_licence(): assert "never as the premise of a trade on its own" in bait +def test_system_prompt_has_v4f_repeat_chart_and_confirmation_knowledge(): + """v4f (12 Aug live session): a WIN taken from the same empty book that lost. + + The session repeated the previous day's shape exactly -- flat open, immediate + drop -- and IH read that SAMENESS as the tell that a trap was forming rather + than a continuation. He then waited for the recovery to actually start before + entering, which is the whole difference from the 11 Aug loss. + """ + prompt = build_system_prompt() + assert "THE CHART DOES NOT REPEAT TWO DAYS RUNNING" in prompt + assert "A MOVE THAT DENIED YOU ENTRY WAS NOT YOUR MOVE" in prompt + assert "AN EMPTY BOOK MEANS A TRAP IS COMING" in prompt + assert "THE SHARPEST RECOVERY NAMES THE LEADING INDEX" in prompt + assert "BOOK WHEN THE PROFIT STOPS GROWING" in prompt + + +def test_v4f_confirmation_rule_does_not_reopen_the_v4e_forecasting_hole(): + """The two rules are a matched pair and must stay one. + + v4e says an empty book is a no-trade; v4f says an empty book means a trap is + coming. Read alone, v4f would license exactly the forecast v4e forbids. The + reconciliation is CONFIRMATION IN PRICE, and both halves have to survive + together or the pair becomes permission to guess. + """ + prompt = build_system_prompt() + section = prompt[prompt.index("AN EMPTY BOOK MEANS A TRAP IS COMING"):] + section = section[: section.index("\n- ")] if "\n- " in section else section + + # It must demand confirmation, not merely a hypothesis... + assert "CONFIRMATION IN PRICE" in section + # ...and it must name what it does NOT tell you. + assert "which side the trap is aimed at" in section + # The v4e rule it reconciles with must still be present and still say HOLD. + assert "A FORECAST OF WHO WILL ARRIVE IS NOT EVIDENCE OF WHO IS SEATED" in prompt + forecast = prompt[prompt.index("A FORECAST OF WHO WILL ARRIVE IS NOT EVIDENCE"):] + forecast = forecast[: forecast.index("\n- ")] if "\n- " in forecast else forecast + assert "the correct output is HOLD" in forecast + + +def test_v4f_exit_rule_stays_on_the_winning_side_only(): + """"Book when profit stops growing" must never become "cut a loser early". + + It is a profit-taking rule. If it drifted into the loss branch it would + contradict DISCIPLINE IS ASYMMETRIC, which puts the patience on winners and + the mechanical exit on losers. + """ + prompt = build_system_prompt() + section = prompt[prompt.index("BOOK WHEN THE PROFIT STOPS GROWING"):] + section = section[: section.index("\n- ")] if "\n- " in section else section + assert "not a price level and not a" in section # "...not a loss." + assert "already captured one momentum" in section.lower() or "captured one momentum" in section + # The asymmetry rule it builds on must still be there. + assert "DISCIPLINE IS ASYMMETRIC BETWEEN WINNERS AND LOSERS" in prompt + + def test_reentry_gate_does_not_contradict_the_exit_rules(): """The re-entry gate must never be readable as a reason to delay an EXIT. diff --git a/Tests/test_nifty_multi_strategy_master.py b/Tests/test_nifty_multi_strategy_master.py index 3d71743..51f2459 100644 --- a/Tests/test_nifty_multi_strategy_master.py +++ b/Tests/test_nifty_multi_strategy_master.py @@ -1,6 +1,7 @@ import hashlib import importlib import importlib.util +import inspect import json import os import re @@ -8,6 +9,7 @@ import tempfile import threading import time +import tomllib import unittest import warnings from contextlib import ExitStack @@ -1356,6 +1358,28 @@ def test_refresh_swallows_broker_exceptions(self): except RuntimeError: self.fail("refresh_index_and_option_ltps should swallow broker errors") + def test_rest_fetcher_marks_its_newest_candle_as_official(self): + """Pure REST mode must satisfy the same watermark contract as true-up.""" + + latest = pd.Timestamp("2026-08-12 09:29:00") + self.broker.fetch_index_1m_ohlc.return_value = pd.DataFrame( + { + "timestamp": [latest], + "open": [100.0], + "high": [101.0], + "low": [99.0], + "close": [100.5], + } + ) + self.broker.fetch_ltp_map.return_value = {} + + # Stop after one real fetcher cycle. Only the waiting primitive is + # replaced; OHLC publication and SharedMarketDataStore remain real. + self.stop_event.wait = MagicMock(side_effect=lambda _seconds: self.stop_event.set()) + self.fetcher.run() + + self.assertEqual(self.store.get("1").official_candle_ts, latest) + # ============================================================================= # TEST SUITE: MARKET DATA SOURCE SELECTOR @@ -1633,8 +1657,10 @@ def test_true_up_overwrites_completed_bar_keeps_forming(self): } ) self.fetcher._run_true_up("test", now_ist=datetime(2026, 5, 15, 10, 17, 40)) - frame = self.store.get("1").frame + snapshot = self.store.get("1") + frame = snapshot.frame self.assertEqual(len(frame), 3) + self.assertEqual(snapshot.official_candle_ts, completed) by_ts = frame.set_index("timestamp") # Completed minute now carries the OFFICIAL candle... self.assertEqual(by_ts.loc[completed]["open"], 100.0) @@ -1904,6 +1930,98 @@ def test_filter_suppresses_only_the_sdk_warning(self): self.assertIn(sdk_message, messages) self.assertIn("some other deprecation", messages) + def test_filter_covers_both_dhanhq_modules_that_call_utcfromtimestamp(self): + """`dhanhq/__init__` imports marketfeed AND fulldepth, and both ship the + same `utc_time` helper (marketfeed.py:523, fulldepth.py:391). + + The runner only subscribes MarketFeed, so the fulldepth call site should + never fire -- but it is one regex branch and the module is imported, so + covering it costs nothing and removes a latent surprise. + """ + pattern = next( + entry[3].pattern + for entry in warnings.filters + if entry[0] == "ignore" + and entry[2] is DeprecationWarning + and entry[1] is not None + and "utcfromtimestamp" in entry[1].pattern + ) + compiled = re.compile(pattern) + self.assertTrue(compiled.match("dhanhq.marketfeed"), pattern) + self.assertTrue(compiled.match("dhanhq.fulldepth"), pattern) + # Still scoped -- it must not swallow the same message from our own code. + self.assertFalse(compiled.match("master_file"), pattern) + + +class TestPytestReappliesTheDhanhqWarningFilter(unittest.TestCase): + """The master's import-time filter is INVISIBLE to pytest, so it must be + repeated in `[tool.pytest.ini_options] filterwarnings`. + + Pytest wraps every test in `catch_warnings()` + `simplefilter("always")`, + which resets `warnings.filters` and discards anything installed at import + time; only its own `-W`/ini entries are re-applied inside that context. + That is why the warning kept appearing in test output for weeks while the + runner itself was silent, and why `test_filter_suppresses_only_the_sdk_warning` + above never caught it -- that test re-installs the filter by hand, which is + exactly the step pytest does NOT do for us. + """ + + @staticmethod + def _ini_filters(): + with open(REPO_ROOT / "pyproject.toml", "rb") as handle: + config = tomllib.load(handle) + return config["tool"]["pytest"]["ini_options"]["filterwarnings"] + + def test_ini_entries_exist_for_both_modules(self): + entries = self._ini_filters() + for module in ("dhanhq.marketfeed", "dhanhq.fulldepth"): + self.assertTrue( + any( + item.startswith("ignore:datetime.datetime.utcfromtimestamp()") + and item.endswith(module) + for item in entries + ), + f"pyproject filterwarnings must silence {module}: {entries}", + ) + + def test_ini_entries_actually_suppress_the_sdk_warning(self): + """Assert the STRINGS work, not merely that they are present. + + The ini format is not the Python API: `warnings._setoption` `re.escape`s + the message and module fields, so these are literals rather than the + regex the master file uses. A plausible-looking entry can silently match + nothing, so this feeds the committed strings through the same parser + pytest uses and then triggers the real SDK helper. + """ + sdk_message = ( + "datetime.datetime.utcfromtimestamp() is deprecated and " + "scheduled for removal in a future version." + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") # what pytest does to us + for item in self._ini_filters(): + warnings._setoption(item) # how pytest parses -W / ini strings + for module, lineno in (("dhanhq.marketfeed", 523), ("dhanhq.fulldepth", 391)): + warnings.warn_explicit( + sdk_message, + DeprecationWarning, + filename=module.replace(".", "/") + ".py", + lineno=lineno, + module=module, + ) + # An unrelated deprecation from the same module must still surface. + warnings.warn_explicit( + "some other deprecation", + DeprecationWarning, + filename="dhanhq/marketfeed.py", + lineno=1, + module="dhanhq.marketfeed", + ) + + messages = [str(item.message) for item in caught] + self.assertNotIn(sdk_message, messages, messages) + self.assertIn("some other deprecation", messages) + # ============================================================================= # TEST SUITE: ADDITIONAL DATACLASS COVERAGE @@ -1928,6 +2046,35 @@ def test_market_snapshot(self): self.assertEqual(snap.frame.iloc[-1]["close"], 100.5) self.assertIsNotNone(snap.candle_signature) + def test_shared_store_snapshot_carries_an_optional_official_candle_watermark(self): + """Consumers can distinguish official REST history from provisional ticks.""" + + frame = pd.DataFrame( + { + "timestamp": [pd.Timestamp("2026-08-12 09:29:00")], + "open": [100.0], + "high": [101.0], + "low": [99.0], + "close": [100.5], + } + ) + store = master_file.SharedMarketDataStore() + + snapshot = store.update("1", frame) + copied = store.get("1") + + self.assertTrue(hasattr(snapshot, "official_candle_ts")) + self.assertIsNone(snapshot.official_candle_ts) + self.assertIsNone(copied.official_candle_ts) + + watermark = pd.Timestamp("2026-08-12 09:29:00") + self.assertIn( + "official_candle_ts", + inspect.signature(store.update).parameters, + ) + store.update("1", frame, official_candle_ts=watermark) + self.assertEqual(store.get("1").official_candle_ts, watermark) + def test_ltp_snapshot(self): """LTPSnapshot identifies a leg and its latest price + fetched time.""" snap = master_file.LTPSnapshot( @@ -9791,6 +9938,65 @@ def test_forming_websocket_minute_waits_for_close_and_true_up_never_repeats_buck worker.process_strategy_frame(corrected) self.assertEqual(agent.decide.call_count, 1) + def test_run_waits_for_the_bucket_final_minute_to_be_official_before_inference(self): + """A clock-complete tick bucket cannot race its minute-close REST true-up. + + At 10:00 the 09:55 five-minute bucket is clock-complete, but its final + 09:59 minute is initially still tick-owned. The first real worker poll + must leave the bucket identity unconsumed. Once the same atomic store + snapshot says REST covers 09:59, the next poll may infer exactly once. + """ + + worker, agent, _logger = self._worker() + worker._run_prebar_safety = MagicMock(return_value=False) + worker._latest_frozen_context = lambda: { + "session_levels": {"prior_accepted_regime": None}, + "momentum_vwap": {}, + "market_structure": {}, + "position_state": {"is_flat": True}, + } + start = pd.Timestamp("2026-08-03 09:55:00") + minutes = pd.DataFrame( + [ + { + "timestamp": start + pd.Timedelta(minutes=offset), + "open": 100.0 + offset, + "high": 101.0 + offset, + "low": 99.0 + offset, + "close": 100.5 + offset, + } + for offset in range(5) + ] + ) + worker.store.update( + "1", + minutes, + official_candle_ts=pd.Timestamp("2026-08-03 09:58:00"), + ) + poll_count = 0 + + def advance_true_up_then_stop(): + nonlocal poll_count + poll_count += 1 + if poll_count == 1: + self.assertEqual(agent.decide.call_count, 0) + self.assertIsNone(worker._last_agent_bar_identity) + worker.store.update( + "1", + minutes, + official_candle_ts=pd.Timestamp("2026-08-03 09:59:00"), + ) + return + self.assertEqual(agent.decide.call_count, 1) + raise StopIteration("test completed two worker polls") + + worker.wait_for_next_poll = advance_true_up_then_stop + + with self.assertRaisesRegex(StopIteration, "two worker polls"): + worker.run() + + self.assertEqual(agent.decide.call_count, 1) + def test_bucket_identity_is_stable_while_content_signature_detects_true_up(self): """Cadence keys on session/bucket; stale-result checks key on frozen OHLC content.""" diff --git a/docs/adr/0012-crash-durable-session-state.md b/docs/adr/0012-crash-durable-session-state.md index 47eae21..a430f5e 100644 --- a/docs/adr/0012-crash-durable-session-state.md +++ b/docs/adr/0012-crash-durable-session-state.md @@ -123,6 +123,26 @@ durability from "guaranteed before the call returns" to a sub-second window — which is the guarantee this ADR was written to provide. At a 0.414 s median, 13 times a session, that trade is not worth making. +##### Measured after the change (2026-08-12, first session on the split) + +| | warnings | median | max | >1s | +|---|---|---|---|---| +| **11 Aug** supervisor (durable, 250 ms threshold) | 254 | 0.909 s | 8.732 s | 114 | +| **11 Aug** trading (durable) | 27 | 0.441 s | 5.111 s | 5 | +| **12 Aug** supervisor (marks, 2 s threshold) | **7** | 2.284 s | 10.065 s | 7 | +| **12 Aug** trading (durable) | **15** | 0.652 s | 2.348 s | 3 | + +Total warnings fell from **281 to 22**, and the supervisor path from 254 to 7 +against a threshold eight times looser. Trading-thread stalls behaved as +predicted — still present, slightly fewer and with a lower maximum, because the +durable document no longer carries the position blobs. + +Two honest caveats. The 12 Aug figures are a partial session (measured at 14:15). +And the worst *marks* write was 10.065 s **without any fsync at all**, which says +the underlying disk contention is real and not purely fsync-driven; what the +split bought is that those seconds now delay supervision instead of a trading +decision, and are labelled as such in the log. + ### Whether resume may restore a LIVE position **Rejected.** In live trading the **broker account** is the authority on what is diff --git a/pyproject.toml b/pyproject.toml index 4383c20..a0f6b3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,32 @@ # [project] section: this file centralizes ruff + mypy configuration, mirroring # the Streamlit Scanner App's setup (the house pattern for CI quality gates). +[tool.pytest.ini_options] +# Pytest wraps every test in `catch_warnings()` + `simplefilter("always")`, which +# RESETS `warnings.filters` and throws away anything a module installed at import +# time. Only the entries below are re-applied inside that context, so the master +# file's own `warnings.filterwarnings(...)` call is invisible to pytest and the +# ignore has to be repeated here or the warning reappears in every test run. +# +# NOTE the format differs from the Python API: in `-W`/ini strings the message +# and module fields are `re.escape`d by `warnings._setoption`, so these are +# LITERALS, not regexes -- the message matches as a prefix and the module is +# anchored exactly. That is why the two dhanhq modules need one line each rather +# than the master file's `dhanhq\.(marketfeed|fulldepth)` alternation. +# +# Scope is deliberately narrow: only dhanhq 2.2.0's pinned, unactionable +# per-tick `utcfromtimestamp()` deprecation. A deprecation from our own code, or +# from any other dependency, must still surface in the test output. +# +# There is deliberately NO leading "default"/"error" entry: that would reset the +# baseline for EVERY warning and change what the suite surfaces beyond the one +# thing being fixed. These two lines are applied on top of pytest's own defaults, +# so every other warning behaves exactly as it did before. +filterwarnings = [ + "ignore:datetime.datetime.utcfromtimestamp() is deprecated:DeprecationWarning:dhanhq.marketfeed", + "ignore:datetime.datetime.utcfromtimestamp() is deprecated:DeprecationWarning:dhanhq.fulldepth", +] + [tool.ruff] # Measured against this codebase: at 100 columns there are ~455 long lines, at # 120 there are ~57 (mostly deliberate log/docstring lines). The code was