CLAUDE.md — AI360Trading Session Guide

Auto-read by Claude Code every session. Follow every rule. No exceptions.


OWNER

Amit Kumar, Haridwar, Uttarakhand, India 6 family members depend on this system for their livelihood. Family is non-technical — cannot fix a broken system. One broken file = zero income that day. Treat every edit as critical.


PROJECT

AI360Trading — Fully automated trading signals + content income platform.


FIRST THING EVERY SESSION

1. Run smartsync.bat   (syncs PC ↔ GitHub — ensures you have latest files)
2. Read CHANGELOG.md   (what changed last)
3. Read SESSION.md     (pending tasks, known issues, current versions)
4. Read the actual file before touching it — NEVER use memory or stale reads

CURRENT FILE VERSIONS

(Read from actual files — verified 2026-07-28: every row below cross-checked against each file’s real docstring/VERSION constant, 1:1, not just spot-checked. Update this date whenever a full pass like that is repeated — don’t let it go stale the way it did sitting at “2026-07-14” for 2 weeks while the table itself kept changing underneath it.)

Trading Engine

| File | Version | |—|—| | trading_bot.py | v15.39 (2026-10-23: OPTION ENTRY GATE — a live Options Alert entry now needs >=15d to expiry (time decay), HV-20<=35% (cheap premium) and NIFTY PCR in 0.6-1.4; fail = stays WAITING; _send_one logs ‘[TG OK]’ for delivery audits; compact alert lines. v15.38 (2026-10-19 owner decision ‘Plan + Trigger’: free infrastructure cannot send instant alerts (price file 5 min + scanner + bot tick = up to ~10-15 min worst case, estimated not measured), so (1) new send_watch_plan() sends a MORNING WATCH PLAN once a day in the Good Morning window (08:44-08:52) to Advance+Premium (teaser to free): up to 5 intraday/option ideas that pass a 7-point higher-timeframe checklist (daily close>EMA50, EMA50>EMA200, weekly close>10-week EMA, weekly RSI>=50, daily RSI 50-68, ADX>=18 with +DI>-DI, yesterday volume>=1.0x avg) with trigger (15m close above previous-day high), chase limit, cancel level, support/resistance — computed fresh from completed bars by new watch_plan.py; NO-PLAN message on bearish-regime days; max 2 ideas when NIFTY is below its 50-DMA and weekly-down; every plan is logged to a new WatchPlan sheet tab and scored next morning (trigger hit? close vs trigger) so its real hit-rate becomes evidence; (2) every entry alert carries _late_alert_line(): ‘alert can be 5-10 min old, do NOT chase above X’ (+0.4% intraday/cash/option, +1.0% swing) and option alerts carry a stock chase limit; (3) entry alerts get watch_plan.htf_line(): Daily/Weekly trend, RSI d/w, ADX, volume. ALL message-text/informational and fail-open — no entry, exit, sizing, gate or paper-ledger logic changed. v15.37 (2026-10-18: free-channel Telegram messages (Good Morning, entry alert, mid-day, close) now carry one honest track-record line via new fail-open _proof_line() — swing & positional only, labelled as such, pointing to the full ledger that also shows intraday + options; History read at most once per 6h; Good Morning (advance) lost its internal-jargon RSI/re-entry line + version/vetoes footer and gained a plain risk note; _fmt12() fixes ‘15:05 PM’ style times in Good Morning/mid-day. No gate, entry, exit or sizing logic touched. v15.36 (2026-08-10: owner reported two real-money losses — PRESTIGE and RADICO, both real trades traded on the option per the alert — that hit the 🔒 20% option loss cap (OPTION_LOSS_CAP_PCT) on an ordinary same-day ~2% stock dip, and both stocks recovered within days afterward. Two real examples isn’t enough evidence to touch a deliberately-set risk ceiling, so instead of changing the cap, built a real evidence-gathering tracker: new OptionCapWatch sheet tab + log_option_cap_watch()/score_option_cap_watch()/optioncap_watch_report(). Logs the stock’s entry/exit price every time the OPTION LOSS CAP exit reason actually fires (hooked right into the existing step_b_monitor_trades() OPTION-type exit branch), then watches the stock’s real price for up to OPTIONCAP_WATCH_DAYS=10 trading days to see if it recovers back to/above entry — same observation-only doctrine as ShadowLedger (v15.28): reads/writes ONLY the new tab, never touches AlertLog/History/BotMemory trade-state, never sends a Telegram alert, never affects the cap itself or any real position. Same session, also verified (not changed): India VIX filter already exists and is live (VIX_MAX=22, blocks new entries above that reading — owner had asked about this thinking it was missing); theta risk is already structurally mitigated via ITM strike selection (~0.65-0.75 delta) rather than a separate gate. Confirmed genuinely missing and NOT added this session (bigger, riskier change to live entry logic, owner asked for it but this needs its own dedicated pass): a sideways/chop (ADX-based) filter on options entries — the Pine dashboard shows ADX/CHOP readings but that’s a separate visualization tool never wired into this file’s actual entry decisions. Verified the recovery/timeout decision logic against the real PRESTIGE/RADICO numbers plus boundary cases (exact-day recovery, exact-day timeout — recovery check correctly runs before the timeout check) before shipping; py_compile clean. v15.35 (2026-08-05: owner asked to review the Cash Intraday strategy’s full History and decide on improvements myself (“final decision is yours”). Pulled all 10 closed Cash Intraday trades to date (2026-07-27→2026-08-05): 7 wins/3 losses, avg win +2.61%, avg loss -0.56%, net +₹1,135 — a healthy, working strategy. But 10/10 exits were via the 3:00 PM clock, never the +12% target or -3% SL, and Max-Price-Seen vs actual Exit was within ~1% on every winner — proving the trailing stop already protects gains on its own, so the fixed clock wasn’t doing risk-control work, only cutting trades short. Separately, this constant was never revisited when NSE moved the real close 15:30→15:40 on 2026-08-03 (appscript.gs v15.34). Fixed: CASH_FORCE_EXIT_HOUR (15,0)→(15,10) + new CASH_FORCE_EXIT_WINDOW_MIN=15, preserving the exact same ~30-min safety buffer before the close this constant always had, just re-anchored to the real close time; step_c_intraday_exit() and its main-loop call-site window both updated to match (was hardcoding “15:00”/”15:15” independent of the constant — now the function reads the constant directly, closing a small dead-constant gap found while making this change). 8/8 boundary-case test passed (14:59→False, 15:00→False, 15:09→False, 15:10→True, 15:17→True, 15:25→True, 15:26→False, 16:10→False); py_compile clean. appscript.gs comments referencing “15:00”/”3 PM” force-exit updated to match (informational only — appscript.gs never independently enforces this exit, trading_bot.py does; no logic change there, not redeployed via clasp). Deliberately did NOT touch CASH_MIN_PCT_CHANGE/CASH_TARGET_PCT/CASH_SL_PCT/the Room gate’s applicability to Cash Intraday — 10 trades over 9 calendar days is too small a sample to safely recalibrate an entry threshold without risking an overfit to noise; the timing shift was the one change the evidence directly supports on its own, since it only gives already-profitable trades already protected by their own trailing stop a bit more runway — it doesn’t change which stocks qualify or add new risk. v15.34 (2026-07-30 same-session follow-up: owner asked to also fix the cross-contamination gap flagged-but-not-fixed in the v15.33 pass — the cooldown key was purely symbol-based, so a losing OPTIONS trade locked out a stock’s CASH/SWING re-entry and vice versa. Fixed: new _cooldown_family(ttype) → OPTION/CASH/STOCK; family param threaded through get_reentry_cooldown_date()/set_reentry_cooldown()/get_loss_cooldown_date()/set_loss_cooldown()/check_reentry_allowed(); BotMemory key now carries family (NSE_TCS_OPTION_LRECD_... vs NSE_TCS_STOCK_LRECD_...); check_all_entry_filters() gained ttype="" to compute family at check time. Side effect: CASH already bypassed this check on the way in, so isolating it also stops a CASH loss from silently cooldowning a future OPTIONS/SWING signal — closes that asymmetry too. Verified with 8 boundary tests (fresh, same-family-blocked, cross-family-allowed both directions incl. CASH, next-day recovery, target-hit family-scoping, real ttype-string mapping against “CASH Intraday”/”📊 Options Alert”/”🎯 SWING (Breakout)”) — all 8 passed; py_compile clean. Also caught and fixed a self-introduced version-labeling slip from the v15.33 pass (several docstrings were mislabeled “v15.34” when they should’ve said v15.33 — fixed via an ordered two-pass rename, re-verified with tests afterward). v15.33 (2026-07-30 owner audit: owner theory — a stock that breaks out, gets stopped on a same-day retest, then genuinely resumes the breakout the next trading day was being locked out of that real move. Confirmed: _exit_trade()’s is_loss = exit_p < ent; set_cd = is_target_hit or is_loss set the SAME REENTRY_COOLDOWN_DAYS=5 (5 trading days, calibrated in v15.0 for a different problem — IDEA re-bought the SAME DAY it hit target, chasing an extended move) for EVERY losing exit too, including a fractional-percent same-day shakeout. v15.17 (2026-07-12) had reused that 5-day number for losses to stop EICHERMOT being re-bought at the same ceiling twice, but never gave losses their own calibrated duration. Owner picked 1 trading day (of 4 presented options) for the loss case; target-hit cooldown stays 5 days, unchanged. Fixed: new LOSS_REENTRY_COOLDOWN_DAYS=1 constant, new set_loss_cooldown()/get_loss_cooldown_date() (BotMemory prefix _LRECD_, parallel to the existing _RECD_ target-hit pair), check_reentry_allowed() checks both and blocks if either active, _exit_trade() routes to the correct one, exit Telegram + Good Morning digest now report the right duration per cooldown type (was hardcoded to always say 5). The resistance-room veto (v15.17, MIN_TARGET_ROOM_ATR) — the precise defence against re-buying the SAME ceiling — is untouched. Verified with 5 hand-built boundary tests via direct function import (fresh/no-cooldown allowed, same-day-after-loss blocked, next-day-after-loss now allowed, next-day-after-target-hit still blocked, 6+ days-after-target-hit allowed) — all 5 passed; py_compile clean. Not fixed this pass (lower priority, not asked): cooldown key is symbol-only, not trade-type-specific — a losing OPTIONS trade still locks a stock’s CASH/SWING re-entry for the same duration and vice versa; CASH entries already bypass the cooldown check entirely (never call check_all_entry_filters). Same session: full sheets inventory (all 12 tabs) + RS-boundary cross-check (trading_bot.py RS_MIN_PCT=5.0 vs appscript.gs LATE_ENTRY_MIN_RS=5) both came back clean, no bug — RS boundary is correctly locked and consistent between engines. v15.32 (2026-07-28 owner-reported bug: subscriber screenshots showed the SAME “TSL UPDATE” alert (identical LTP/SL/P&L) firing every ~5 min for nearly an hour on one live trade (ETERNAL). Root cause: calc_new_tsl()’s breakeven/lock1/chandelier formulas are deterministic functions of unchanging inputs (e.g. ent*1.002) and return an unrounded float, while the stored TSL is rounded to paisa (int(round(price*100))) — so a re-computation of the SAME real value (e.g. 296.592 vs stored 296.59) sat a fraction of a paisa above the stored value on every tick, and the plain new_tsl > cur_tsl float compare was true every single time even though nothing had actually changed. Reproduced exactly with the real screenshot numbers (ent=296.00 → recomputes to 296.592 forever, stored rounds to 296.59, old guard fires every cycle). Fixed: the comparison guard now compares in integer paise (int(round(new_tsl*100)) > int(round(cur_tsl*100))), matching the storage precision — two computations of the same real value now compare equal, not greater, while a genuine improvement (price moved, even by 1 paisa) still correctly fires. Verified both cases with the real numbers before shipping. No change to SL/exit correctness — the stored TSL value itself was never wrong, only the alert was spamming. 2026-07-26 Telegram text audit: send_midday_pulse() hardcoded “Entry window ends 2:30 PM” for the bullish case, but ENTRY_WINDOW_BULLISH_END moved 14:30→15:05 back in v15.29 (2026-07-23) — subscribers were being told the window closed 35 minutes early, every day since. Fixed to read the real constant, same pattern send_good_morning() already used correctly. appscript.gs also got a stale-comment-only fix (said “10:30 AM”, code already read the live CONFIG.CASH_ENTRY_WINDOW constant — no behavior change, not redeployed via clasp). v15.30 (2026-07-26 full-system audit: CASH_ENTRY_WINDOW_END was still (10, 30) while appscript.gs had already extended its own CONFIG.CASH_ENTRY_WINDOW to 13:30 (v15.29/30, to catch 4%+ gap/catalyst movers building their move after 10:30) — the promotion-side cutoff was never updated to match, so afternoon cash WAITING rows were queued by appscript.gs and then silently starved here, never promoted to TRADED. Fixed to (13, 30) to match. Also corrected a stray “v15.30” comment label on the ENTRY_WINDOW_BULLISH_END line to “v15.29” (that change already shipped under v15.29, confirmed against this table’s own history — the mislabel would have collided with this session’s real v15.30). No gate/threshold logic touched otherwise. 2026-07-23 post-midnight: ENTRY_WINDOW_BULLISH_END moved 14:30→15:05 — a live 07-21 candidate (M&MFIN) appeared ~15:07 and was time-blocked every tick to close; TIME was the #2 blocker (29% of all 58-session filter checks) after RSI. Bearish window (11:00) deliberately NOT touched — tied to the v15.6 “4 losses” evidence. 5/5 boundary-case unit test passed (incl. new 15:05/15:06 edge). 2026-07-22 post-midnight: VERSION constant was stuck at “v15.28” — the RSI dip-tolerance fix below was committed/documented as v15.29 everywhere except this one banner-text string; fixed, cosmetic only, the actual fix was confirmed live/running the whole time via log inspection. 2026-07-21 late night: RSI hot-leader exception given a small dip-tolerance noise floor — RSI_HOT_DIP_TOLERANCE=0.3; the “stock up today” condition now accepts day_pct > -0.3% instead of requiring > 0 exactly. Evidence: 58-session/3.5-week log audit found RSI was the #1 entry blocker (45% of all filter-checks, vs RS 14% and Volume 12%), and 812 of those 1,311 RSI blocks (62%) were sitting in the 65-75 exception window with NIFTY regime bullish ~90% of the time — day_pct was flickering across zero on live 5-min ticks (a real leader doesn’t move in a straight line), not the regime or a genuinely red day, killing the exception on noise rather than substance. RSI_MAX_BULLISH(65)/RSI_HOT_MAX(75) themselves were NOT touched — only the noisy zero-crossing check. 13/13 unit tests pass (boundary cases at exactly -0.3%, fail-closed on missing data, fail-open on missing RSI, base cap unchanged above 75 and in bearish regime); py_compile clean. v15.28: volume-gate shadow tracker — new ShadowLedger sheet tab; candidates that pass every filter except the 1.5x volume gate get logged as never-traded phantom entries and scored later with the same target/SL/TIME_STOP_DAYS rules real trades use, to measure whether the hard gate is well-calibrated; BOT_MODE=shadow_report for an on-demand summary; observation-only, never touches AlertLog/History/BotMemory trade-state; v15.27: full-system audit — removed dead ACCUM_ memory key that clean_mem never pruned; read_atr_from_nifty200/_read_opt_tag_from_nifty200 switched from hardcoded row[28]/row[35] to header-name lookup (_find_nifty200_col) so a future inserted column can’t silently corrupt SL/target/option math; stale “12-day”/BUY_PE comments fixed; v15.26 OWNER RULE — options are INTRADAY/1-2 day holds only; OPTION_MAX_TDAYS=2 replaces the base 12-trading-day rule for ALL option ledger rows (20% cap + expiry-week backstop unchanged); entry alerts carry _trade_style_lines — CASH/MTF vs cash vs accumulate + “Entry valid jab tak price SL ke upar” + TradingView chart line (DAILY/15m/WEEKLY); v15.25 option ledger follows the alert’s rules; v15.24 unified strike grid + premium-aware option P/L; v15.23 full bug pack) | | entry_quality.py | v1.0 (reversal veto + target room + composite ranking) | | option_intelligence.py | v1.4 (2026-07-20: doc-only fix — stale ‘BUY_PE’ removed from action docstring, PE path confirmed dead since buy-side-only 2026-06-05; 2026-07-18: option alert wording = intraday/max 1-2 day hold, current-expiry rationale, 2-day exit; v1.3 strike table unified w/ appscript + est_option_leverage; v1.2 earnings via bm_data rows) | | institutional_edges.py | v1.1 (2026-07-16: volume gate time-adjusted — partial-day reading ÷ expected session fraction, 1.5× bar unchanged) | | fetch_earnings.py | v1.2 (2026-07-17: BSE long names → real NSE symbols via official EQUITY_L.csv (first-token keys were mostly dead); unmappable dropped; fail-open to old behavior; v1.1 atomic BotMemory write) | | fetch_bhavcopy.py | v1.2 (2026-10-23: DLV delivery rows trimmed to the tradeable universe (Nifty200+CashWatchlist; 2,694 -> ~220 BotMemory rows), fail-open; v1.1 (2026-10-23: PCR now from the official F&O bhavcopy archive (old cookie API never worked on CI, MKT_PCR_* were never cached); v1.0 (Batch 4, Mon-Fri 20:00 IST) | | fetch_smallmidcap.py | v1.5 (2026-07-18: easy-Hinglish digest — CASH-only rule, entry-valid line, TradingView DAILY line; v1.4 REAL 5d volume + SmallMidLive board + target floor ≥5% w/ honest R:R) | | fetch_rs.py | v2.2 (2026-07-26 sheet/formula audit: NIFTY50 row’s own 20DMA now fed too — this script always deliberately skipped the NIFTY50 row from RS targets, but that also meant col E (20_DMA) for row 2 was never fed by anything and stayed on a live GOOGLEFINANCE formula forever, the one input get_market_regime() reads directly with NO yfinance fallback for the whole bot’s bull/bear decision. Now computed from the ^NSEI daily series already downloaded as a benchmark, no extra API call; 50/200_DMA etc. deliberately left blank for row 2 as before — the regime gate never reads them; v2.1 2026-07-24: GOOGLEFINANCE migration slow lane — now also writes 20/50/200_DMA, 52_Weeks_Low/High, Avg_Volume(20D), Pivot_Resistance, VCP Status, Days Since Low as Python-fed plain values, retiring their GOOGLEFINANCE formulas; lookback bumped 3mo→15mo to cover the 200-DMA/52wk windows from the same single download; same schedule (08:45+12:30 IST); v2.0 2026-07-17: + TRUE ATR(14) feed into Nifty200 col AC — old formula was ONE day’s high−low feeding all SL/targets; + NIFTYBEES→^NSEI benchmark freshness fallback; workflow moved to own fetch-rs concurrency group after trading-bot group froze RS 07-13→07-17) | | fetch_live_prices.py | v1.1 (2026-07-26 sheet/formula audit: the NIFTY50 index row was being SKIPPED entirely — same “NSE:XXX→XXX.NS” logic would have produced the invalid ticker “NIFTY50.NS” — leaving row 2’s CMP/%Change on its original live GOOGLEFINANCE formula forever, the exact single-point-of-failure this script exists to remove, just for the ONE row get_market_regime() reads directly with no yfinance fallback. Fixed: NIFTY50 row now fed like every other row, using ^NSEI instead of being skipped; v1.0 2026-07-24: NEW — GOOGLEFINANCE migration fast lane. Every 5 min, market hours, writes real-time CMP/%Change/Live_Volume (new helper col AL) to Nifty200 — the 3 columns that need true intraday freshness, unlike fetch_rs.py’s 2x/day slow lane. Verified before cutover: CMP/%Change tracked live GOOGLEFINANCE within a mean 0.13%/p90 0.4%/max ~1% deviation across all 200 symbols. Volume_vs_Avg % (col O) formula repointed to read Live_Volume instead of calling GOOGLEFINANCE(A,”volume”) directly — same fail-open doctrine as fetch_rs.py: a failed fetch (per-symbol or whole-batch) leaves the existing cell untouched, never blanked) | | fetch_fii_dii.py | v1.1 (2026-07-28: fixed a real silent data-loss bug — confirmed via live BotMemory + run logs that 2 real trading days, 2026-07-21 and 2026-07-23, lost every MKT_FII_/MKT_DII_ key despite the script logging success. Root cause: 8 separate unbatched bm_set() calls per run, each re-reading the whole ~3,800-row sheet with no pacing — same class of bug already fixed in refresh_cashwatchlist.py v1.3/fetch_earnings.py v1.1. Fixed with the same atomic single-write pattern: new _bm_set_many() reads once, writes once) | | fetch_index_meta.py | v1.0 (2026-07-17: weekly Saturday — refreshes Nifty200 col AJ Options N50/YES/”” + col AK N200 YES/EX from NSE official lists; Telegram notice on drift; fail-open, never adds rows) | | fetch_dividends.py | v1.0 (2026-07-30 NEW — weekly Saturday 11:15 IST, own concurrency group. Owner asked which cash stocks “benefit more with dividend, bonus etc.” — nothing in the day-to-day cash/swing/options engine carried any dividend/bonus data before this (only a separate trailing dividendYield used by generate_longterm.py for the unrelated 12-month LongTermSignals sheet). NSE’s real forward-looking corporate-actions calendar needs a protected session-cookie API this project deliberately avoids (same fragility class as the 2026-05-27 holiday-list outage) — so this feed is honestly TRAILING history, not a forward calendar. Adds 4 new columns via per-symbol yfinance Ticker.dividends/Ticker.splits calls (~0.8s/symbol, ~200 symbols in ~3 min, no bulk endpoint exists for this data unlike price bars): Div_Yield_%_TTM (col AO, computed directly from the raw trailing-365-day dividend sum ÷ live CMP, not trusted from yfinance’s .info summary field which showed inconsistent units in spot checks), Last_Div_Date (col AP), Last_Bonus_Split_Date (col AQ), Last_Bonus_Split_Ratio (col AR — Indian bonus issues and true stock splits are indistinguishable in this data source, labelled honestly as “or”). Verified live: VEDL 10.11% yield (highest, correct — known high-payout PSU), IOC 7.15%, BPCL 7.12%, COALINDIA 6.33%, ITC 5.08%, TCS 4.56% — all matches known real payers; 186/200 symbols have dividend history, 136/200 have a bonus/split on record, 0 fetch errors. DELIBERATELY NOT WIRED INTO ANY GATE — informational only, same doctrine as fetch_ath.py/Sector_Trend/ShadowLedger. Fail-open per symbol (never-paid = correctly blank, not an error), columns found/created by header name, sheet grid auto-expanded 40→44 cols to fit) | | fetch_bottom_levels.py | v1.3 (2026-10-18: + Close_High_50D/CH50_Breakout_5D (50-day CLOSING-high breakout, the best simple rule in the 5y/194-stock backtest; lookbacks 50-100d all ~+3.3%/trade), SIP_Zone, Breakout_Signal formula columns, trailing Sector_Trend pull; header-based, 38 cols. NOTE: the owner turned the tab into a Google Sheets TABLE (‘BottomScannerTable’, banded + filter) — its saved column formats override API formats, so Close_High_50D/EMA50 may DISPLAY as dates (values are real numbers; right-click column > Format > Number fixes it by hand; the table API returned 500) — publishers read UNFORMATTED_VALUE. The owner’s old table filter pointed at columns that moved in the rebuild. v1.1 (2026-10-18: + EMA50/ADX_14/TLB_Breakout_5D (exact Pine trendline-breakout port) + Trend_Setup column; Bottom_Stage renamed Base_Stage (backtest: bottom-fishing no better than random); new observation-only TrendWatch tab forward-tracks every TREND BREAKOUT signal with the backtest’s exit rule — never touches trade state/alerts. v1.0 2026-10-18 NEW — slim BottomScanner tab: ~12 Nifty200 pull-through formulas + Python-fed levels (Prev_Day/Week High-Low, Support_1/2, Resistance_1, Base_Range_20D%, Retest_Level/Status) + in-sheet Bottom_Stage / Intraday_Setup formulas; runs as a continue-on-error step in fetch_rs.yml (08:45 + 12:30 IST); --build recreates layout. INFORMATIONAL ONLY, header-name lookups) | | publish_signal_lists.py | v1.0 (2026-10-18 NEW — reads BottomScanner (UNFORMATTED_VALUE), writes _data/signal_lists.json for the public pages /sip-accumulation/ (SIP_Zone) and /breakout-signals/ (Breakout_Signal), top 15 each, + NIFTY-vs-50d-EMA market line via yfinance. TEASER ONLY (symbol, sector, price, descriptive numbers, a level — never entry/SL/target/size, those stay in Telegram). Fail-open (existing JSON untouched on any error; refuses <100 scanner rows). Runs as a continue-on-error step in fetch_rs.yml (08:45 + 12:30 IST) which now has contents: write and commits only that file (pull –rebase before push). Pages carry the honest backtest note (SIP: buying drawdowns showed no timing edge; breakout: ~41% win, median trade a small loss, mostly 2023, survivorship-biased)) | | telegram_links.py + .github/workflows/telegram-links.yml | v1.0 (2026-10-23 NEW — manual workflow creates one NAMED Telegram invite link per traffic source into _data/telegram_links.json; money_funnel.tg_link(source) uses it with a plain-link fallback; Facebook captions, YouTube descriptions/comments and FB/IG compact funnel use their own links so Telegram’s ‘N joined’ per link shows which platform brings members) | | book_links.py | v1.0 (2026-10-23 NEW — ‘Further reading’ book block for articles + video/lesson descriptions from _data/book_refs.json; shows a book ONLY when its link is filled with the owner’s own referral URL (all empty today = zero output change); credits book+author, own-words idea, affiliate disclosure always shown with a link; fail-open) | | thumb_kit.py | v1.0 (2026-10-23 NEW — real-chart thumbnail/share-card library (stock_card / market_card / concept_card), Pillow + yfinance, fail-open, no emoji, wired 2026-10-23 into the morning reel, Trader’s Notebook reel and story Shorts thumbnails, all fail-open to the previous design; also wired 2026-10-23 into generate_articles.py as a per-article 1200x630 share image (front-matter image:, committed from public/image/articles/)) | | story_short.py | v1.0 (2026-10-18 NEW — real-data chart-story Shorts: Hindi stock-breakout story (from _data/signal_lists.json, real yfinance closes) + English NIFTY-vs-50d-EMA story, Pillow-animated, free; no entry/SL/target on screen; fail-open to classic info-card Shorts; daily-shorts.yml now Mon/Wed/Fri only) | | lesson_bank.py / lesson_engine.py / lesson_visuals.py | v1.1 (2026-10-18: REGION + LANGUAGE editions — --region us --lang en (US stocks, dollars, en-US voice), --region in --lang hi (Devanagari narration, hi-IN voice), --region in --lang en; US stats computed live on 42 large US stocks (win 41%, avg +1.2% vs random +0.9% — a THIN edge, the narration says so when edge over random <1pt), India uses the 194-stock backtest; translations must keep every English-master number (numbers_preserved); .srt + thumbnail per edition. v1.0 2026-10-18 NEW — evergreen LESSON engine for serious traders: 60-topic bank in 6 types (rule_chart/autopsy/research/risk_math/psychology/book_idea) with a balanced picker; quality_gate() (real number from our data + a ‘Rule:’ line + an honest limit + no hype phrases + length) must pass before a lesson can publish; 1280x720 Pillow-animated real-chart scenes + edge_tts narration per scene. v1.0 builds a LOCAL SAMPLE ONLY (python lesson_engine.py --sample, output in _drafts_local/lesson_sample/) and NEVER uploads — wiring to a workflow/upload is a separate owner-approved step. Needs edge-tts 7.3.1 (7.2.8 gets HTTP 403)) | | lesson_runner.py + lesson_builders.py + .github/workflows/lessons-weekly.yml | v1.0 (2026-10-18 NEW — WEEKLY evergreen lessons replace the daily slide-style education videos (daily-videos.yml cron PAUSED, manual dispatch still works). One lesson per week per edition: US English goes live Sat 14:00 UTC, India Hindi Sun 13:30 UTC, uploaded PRIVATE with publishAt by the Wednesday 06:17 UTC run so YouTube publishes at the exact minute (GitHub schedule delays irrelevant) and the owner can review/cancel in Studio first. Queue = lesson_builders.QUEUE (rc01 breakout rule, rm01 loss maths, rs01 buy-the-dip test computed live, au01 our own best/worst ledger trades); every lesson must pass lesson_bank.quality_gate() on the English master and translations must keep every number; any failure = nothing published + Telegram note + retry next week; empty queue = Telegram alert, never repeats. Kill switch = repo variable LESSONS_ENABLED (must be ‘true’); mode=private-test uploads PRIVATE, no announce, not marked done. State file _data/lessons_done.json. No AI call needed. Add a lesson = add a builder + QUEUE entry) | | watch_plan.py | v1.0 (2026-10-19 NEW — morning watch plan + higher-timeframe checklist + WatchPlan tab logging/scoring for trading_bot.py v15.38; informational only, fail-open, buy-side (CE) ideas only) | | fetch_ath.py | v1.0 (2026-07-29 NEW — weekly Saturday 10:45 IST, own concurrency group. Owner noticed DIVISLAB and EXIDEIND both got a “breakout” Breakout_Stage tag the same day despite not being the same setup; traced it to Breakout_Stage only ever comparing CMP to 52_Weeks_High, never the stock’s TRUE all-time high — a bounce toward a depressed 52-week ceiling (EXIDEIND, still 24.8% below its real 2024-06-25 high) gets the identical label as a genuine new high (DIVISLAB, which WAS its all-time high that day). Adds 2 new columns via one batched yfinance period="max" download (full history back to IPO, ~5,800+ daily bars for stocks like DIVISLAB/EXIDEIND): All_Time_High (col AM) and %_from_ATH (col AN) — verified live same session (DIVISLAB -0.43%, EXIDEIND -29.72%, matching the manual yfinance check). DELIBERATELY NOT WIRED INTO ANY GATE — owner instruction: “add the ATH check, but don’t fix gate yet.” Breakout_Stage/Trade_Type/every appscript.gs GATE are untouched; informational only, same doctrine as Sector_Trend when it was first added (v15.30). Fail-open per symbol, columns found/created by header name, sheet grid auto-expanded 38→40 cols to fit) | | appscript.gs | v15.37 (LIVE, clasp-deployed 2026-08-11: owner rule, night before real-money trading began — options should only fire on genuinely momentum-confirmed stocks, “cheap or retest” OK, otherwise fall back to cash/swing/positional. Real root cause: _mapTradeType() assigned Options Alert purely from priority>=28 && atrPct>2.0, zero check the stock had actually confirmed a real move. Fixed: also requires isBreakoutStage (already computed earlier in the scan loop — stage.includes("BREAKOUT CONFIRMED") or "BREAKOUT ALERT"), which already covers a RETEST of a confirmed breakout (the owner’s “cheap” case stays allowed) — only unconfirmed/early-stage candidates get excluded, and they fall through to the existing Intraday/Swing/Positional logic rather than being blocked. Verified against real live 2026-08-11 data before shipping: that day’s 4 real Options Alert candidates (LAURUSLABS, RADICO, TVSMOTOR, LENSKART) all already carried BREAKOUT CONFIRMED — the fix tightens a rare gap case, didn’t block any real candidate seen live. 8/8 boundary-case unit tests passed (own first attempt had 4 wrong-expected-value test-fixture mistakes, caught and corrected before trusting the result) before deploying. Same session: researched a real 1:3-1:5 target-ratio request — pulled all 13 real Options Alert trades from History and computed exact math: the CURRENT 1:1.9 target was only reached in 1/13 (8%) of real trades, every other trade decided by the 2-day time exit or loss cap, never the target price; raising to 1:3-1:5 would have been reached in 0/13 — concluded and told the owner the target ratio isn’t the real lever on P/L here, deliberately did not change it. Also researched real NSE option-chain liquidity data — every free source found uses the same fragile session-cookie scraping pattern this project already explicitly avoids elsewhere (fetch_dividends.py’s own docstring cites the 2026-05-27 holiday-list outage) — deliberately declined to build this, existing ITM-current-expiry strike selection already structurally favors the most liquid strikes. Also confirmed via a real live test (gh workflow run + checked createdAt vs startedAt) that workflow_dispatch bypasses GitHub’s schedule: queue delay entirely (1 second vs. the ~2hr delay schedule: triggers sit in) — gave the owner exact setup steps for an external cron+PAT trigger (his own account action, not something to generate on his behalf); explicitly told him not to paste the token into chat after almost asking for it, caught and corrected. v15.34 (LIVE, clasp-deployed 2026-08-03: message-text-only fix — NSE extended the F&O market close 15:30→15:40 effective today (verified via multiple independent news sources, not memory/guesswork, per the project’s holiday-list doctrine of never hardcoding schedule facts without a real source). Two premarket/night-scan “is this live or stale data” boundary checks (isLiveOpt line ~1177, isLiveHours line ~2232) were hardcoded <= 1530; both moved to <= 1540 so the bot doesn’t start telling subscribers “yesterday’s close” advisory text during the now-legitimately-still-open 15:31-15:40 window. Zero gate/scoring/entry logic touched — these only control which advisory sentence gets appended to a Telegram message. Deliberately NOT touched: VOL_SESSION_CURVE/VOL_SESSION_LEN_MIN=375 (both institutional_edges.py and this file’s own copy) — NSE’s change isn’t a simple “+10 min”, it replaces the old continuous-trading-into-15:30 model with a 15:15 continuous-trading cutoff + a 15:15-15:35 Closing Auction Session (a qualitatively different volume-accumulation shape for the last ~20 min, not just a shifted endpoint) — no real post-change data exists yet to know how yfinance/GOOGLEFINANCE volume reporting behaves through that window, so the time-fair PACE curve was left alone pending real observation, consistent with 🔒 NEVER CHANGE doctrine. v15.33 (LIVE, clasp-deployed 2026-07-29: v15.33 — same RR-math bug fixed for Positional + Swing, after explaining the v15.32 finding in more depth. Positional: ATR_TGT_POSITIONAL(4.0)/ATR_SL_POSITIONAL(2.5) was a FIXED RR of 1.6, and the v15.18 +5% floor could NEVER rescue it (proved algebraically self-contradictory: needs ATR%<1.25% to matter but only activates at ATR%≥1.3%, so both can never be true) — confirmed with HCLTECH’s real 07-29 numbers, only a ~0.278×ATR-wide DMA-anchor coincidence could ever pass it. Fixed: ATR_TGT_POSITIONAL 4.0→4.7 (RR=1.88, unconditional). Swing (non-leader): ATR_TGT_SWING(3.0)/ATR_SL_SWING(2.0, never DMA-anchored) was a FIXED RR of 1.5, only rescued by the floor inside a ~0.09-percentage-point ATR% sliver (1.30%-1.39%). Fixed: ATR_TGT_SWING 3.0→3.8 (RR=1.9, unconditional). ATR_TGT_SWING_LEADER 4.0→4.8 alongside it (not explicitly requested, raised to preserve the same +1.0×ATR edge the top-3-ranked leader bonus always had over base swing). Stop distances unchanged, only targets moved out — same shape as v15.32. Together with v15.32, all 5 trade-type branches (Base/Momentum/Swing/Positional/Intraday) now clear MIN_RR unconditionally. v15.32 — intraday RR gate was mathematically impossible to pass. Owner asked why today’s top gainers (35-symbol screenshot set) weren’t reaching WAITING/TRADED; traced with live ScanDiag+Nifty200+AlertLog data (AlertLog was completely empty). RS≥5 gate correctly blocked 25/31 in-universe gainers (working as designed). But the “⚡ Intraday” branch’s SL=CMP-ATR×1.5/target=CMP+ATR×2.0 is a FIXED RR of exactly 1.333 for every stock, always — MIN_RR=1.8 could never be satisfied. Confirmed live: DIVISLAB (today’s best RS-passing candidate, RS 13.96, Master_Score 44) rejected with the exact math (SL ₹7547.81, target ₹8100.08, RR 1.33). git log -p showed MIN_RR/ATR_SL_INTRADAY/ATR_TGT_INTRADAY were all introduced together in commit 07b988c (2026-05-22, v15.6) — broken from day one, not a drift bug, live ~9 weeks; only the separate isMomentumEntry() breakout path (RR=2.0) could ever produce an intraday-style entry via the standard scan. Fixed (owner chose from 4 presented options): ATR_TGT_INTRADAY 2.0→2.8 (RR=1.867≥1.8), stop distance unchanged. Same root math issue found milder in Positional (baseline RR 1.6, floor can’t rescue it) and non-leader Swing (baseline RR 1.5, narrow 1.30-1.39% ATR% band only) branches — NOT fixed this pass, owner still deciding, see SESSION.md 2026-07-29. v15.31 — scan diagnostic made actually readable. The v15.28 diagnostic (below) was built to explain why bullish-day candidates weren’t reaching AlertLog’s WAITING queue but only ever wrote to Apps Script’s execution log, which needs a one-time GCP project link never done on this project (clasp logs → “GCP project ID is not set”) — so it was write-only and the 07-21 mystery was never resolved, just silently recurring (confirmed: 07-13/16/17/20 all BULLISH all day with 0 entries, unexplained). Added a skipReasons Map that records the EXACT gate that rejects each stock, set inline next to each existing continue (11 gates + bearish hard-block + standard bullish filter) using that gate’s own already-computed variables — cannot drift out of sync with the real logic. New _writeScanDiag() persists the top-5-candidate finding to a new “ScanDiag” sheet tab, overwritten every ~5-min scan (not appended, zero sheet growth), readable with normal Sheets access. Verified live same-session: tab appeared immediately, correctly showing today’s top 5 (TITAN/NYKAA/RADICO/BHARATFORG/BIOCON) all rejected for “BEARISH + RS X < 15” — matches manual trace exactly. Zero gate/threshold logic touched. v15.30 — Sector_Trend column (Nifty200 col V, TAILWIND/HEADWIND) was computed every scan but read nowhere; now surfaced as an extra line on the premium options Telegram alert (“market+sector+stock sab same direction” / “extra caution”) — owner’s own 3-way alignment rule made visible, deliberately INFO not a new hard gate so it doesn’t shrink the candidate pool. v15.29 — afternoon-move blind spot: CASH_ENTRY_WINDOW 10:30→13:30 (the 4%+ gap/catalyst intraday path only ever scanned till 10:30 AM), MOM_WINDOW_END 11:30→14:30 (sector-momentum scan same blind spot), RESULT_DAY_SKIP_PCT 6.0→10.0 (any isolated single-stock move >6% was auto-skipped as suspected earnings/corp-action unless 3+ other sector stocks also moved — ordinary 6-10% breakout days were being thrown out); CORP_ACTION_SKIP_PCT(15) and all bearish-regime thresholds deliberately untouched. v15.28: candidate-starvation diagnostic — read-only [DIAG] log line at the end of the WAITING-candidate loop showing the day’s top-5 Master_Score stocks and whether each reached the queue (QUEUED/NOT-QUEUED + score/pri/rs/sector/ttype); added after tracing a 07-21 bullish-day mystery (only TITAN ever queued despite several other clean-on-paper STRONG BUY names) as far as static evidence allows — touches zero gate logic, pure Logger.log, wrapped in try/catch. v15.27: full-system audit — one-time header-position guard on the Nifty200 ATR (col 28) + Options-tag (col 35) reads in _runScanner (used by both the main scan and momentum scan); on header mismatch both fail SAFE to 0/null instead of trusting a shifted column, logs [COLUMN-DRIFT]; v15.26 OWNER RULE — option expiry = CURRENT month (≤5d rollover → next month, NEVER month+2; kills MIN_DAYS_BASE_ENTRY:40 that put a Jul signal on the dead Sep chain); option alert = intraday/1-2 din hold in easy Hinglish + entry-valid + TradingView lines; night-scan “Kaise trade kare” legend; v15.25 strike grid unified; deploy via .\deploy_appscript.ps1) |

Long-Term Signals

| File | Version | |—|—| | generate_longterm.py | v1.7 (2026-07-26 full-system audit: internal VERSION constant was stuck at “v1.6” while the docstring header already said v1.7 — banner-text stale-stamp only, no logic affected) | | refresh_cashwatchlist.py | v1.3 | | fetch_holidays.py | v1.4 (2026-07-28: preventative hardening — same per-key-write shape as the confirmed fetch_fii_dii.py v1.1 data-loss bug, no confirmed loss here but fixed with the same proven atomic-write pattern anyway, cheap to apply and this data gates trading-day math bot-wide) |

Core Infrastructure

| File | Version | |—|—| | ai_client.py | v2.6 (2026-10-18: Groq/Gemini model lists are now discovered LIVE each run (provider’s own list, ranked) — the hardcoded lists had gone stale (4 Groq models retired, Gemini 2.5 EOL Oct 2026) and every video/article job was silently using templates; static lists kept only as last-resort fallback. Veo 2 path in ImageVideoClient is dead (no free tier) — left in place, fail-open, unused. v2.5 (2026-07-26 full-system audit: __main__ test-banner print() was hardcoded “v2.4”, one release stale — cosmetic only, python ai_client.py direct-invocation path only) | | human_touch.py | v2.6 (2026-08-06: owner-reported TTS bug — Hindi voice said “paar” instead of “par” (e.g. “Nifty 24500 par hai” → “24500 paar hai”) in every video type. Root cause: all 4 TTS generators feed Roman-script Hinglish straight to edge_tts, and Roman “par” is ambiguous (also the spelling of the English word “par,” which is genuinely long-a) — the Hindi voices default to the long-a reading regardless of intended sense. New safe_tts_par() — word-boundary regex swaps standalone “par” for Devanagari “पर” (unambiguous, correct for every Hindi sense) — audio-string only, never applied to on-screen text (would recreate the “पा” thumbnail artifact safe_thumbnail_text() exists to strip). Wired into generate_reel.py/generate_education.py (unconditional, Hindi-only voices) and generate_reel_morning.py/generate_shorts.py (conditional on the Hindi voice branch, English branches untouched). Confirmed via grep that the genuine English idiom (“par value”/”on par”) never appears anywhere in this codebase’s content, so the blanket substitution is safe. Verified: py_compile clean on all 5 files; function tested directly against the owner’s exact example and against substring false-positives (“partnership”). Not audio-verified — cannot listen to synthesized speech, owner to confirm after next video. Also noted, not fixed: safe_tts_price() (v2.2) has zero call sites anywhere in the repo — an earlier fix that was never wired in; v2.5 2026-07-26: get_video_tags() returned the SAME tags in the SAME order every call for a given mode — confirmed live, every morning reel had byte-identical hashtags. Same “100% fixed template” risk class as the v2.4 Facebook fix below, which already proved this pattern can cost real reach (lost Meta Recommendations eligibility). Fixed: a small CORE_TAGS set (brand + top search terms) stays first, everything else is now shuffled from the full relevant pool each call — genuine day-to-day variation, no caller code changed; v2.4 2026-07-23: FB_CAPTION_OPENERS/FB_HASHTAG_SETS + fb_caption_opener()/fb_hashtags() — Facebook captions were a byte-fixed template every post, a real contributor to the Page losing Meta Recommendations eligibility; wired into upload_facebook.py + daily-articles.yml’s article-share step; v2.3 2026-07-19: all hook libraries rewritten number-first for retention; no invented stats; {holiday}/{week} placeholders preserved) | | token_refresh.py | v2.2 | | content_calendar.py | v2.4 (2026-08-04: authored real “points” content for all 260 EDUCATION_COURSE slide headings, 52 weeks × 5 headings, ~1042 factual bullets — closes the root cause of the generate_education.py fallback-relevance bug found the same day: every topic previously defined only a slide “heading”, never “points”, so the AI-failure fallback path had zero real content to draw on for any topic, ever. Verified via a live end-to-end test: the fallback path’s 5 core lesson slides per topic now show genuine, topic-specific content. MONDAY_TOPICS/TUESDAY_TOPICS/etc. (different topic pool, different generator) untouched) | | money_funnel.py | v1.2 (2026-07-20: article_cta_html() now plugs a rotating calculator too — was the only funnel surface v1.1’s tool_plug() rollout skipped, and articles are the site’s highest-traffic surface; v1.1 TOOL_PAGES + tool_plug() daily calculator rotation in funnel_block/comment_text — video→website traffic loop; single source of truth for broker links in CONTENT — Zerodha/Dhan/Groww/CoinDCX) | | indian_holidays.py | current (no version tag) | | health_watchdog.py | v1.2 (2026-07-20: CHECK 7 — payment-page integrity; fetches the live membership/shop/starter-kit pages daily, alerts if the UPI ID or WhatsApp number ever differs from the pinned known-good value — detects a UPI-swap attack via repo compromise, since payments have no gateway yet; v1.1 GH_TOKEN PAT expiry early-warning) |

Content Generators

| File | Version | |—|—| | generate_education.py | v1.4 (2026-08-04, same-day follow-up: owner asked to actually check video quality, not just trust the earlier fix. Ran the real pipeline end-to-end locally (real generate_edu_slides(), real make_edu_slide(), real edge_tts audio — stopped deliberately before video encode/YouTube upload, no ffmpeg locally + never publish without explicit confirmation). Found a WORSE pre-existing bug the repeat-fix alone didn’t touch: content_calendar.py’s 52-week EDUCATION_COURSE topics only ever define a slide “heading”, never “points” (confirmed via grep — the 13 real “points” entries in that file all belong to a different, unrelated topic list) — so whenever the AI script call fails (real production path, not just local no-API-key testing), every fallback slide got a real heading but 100% generic, topic-disconnected body text; a live test “Moving Averages” video’s fallback slides never said “EMA” once. Fixed: expand_slide_content() now prepends a heading/topic-naming intro sentence before any filler when content is empty. Re-verified via the same live end-to-end test: every slide now names its own topic in sentence 1; max filler reuse dropped 8x→4x; total script words 2726→1966 (closer to, still above, the 10-12min target — the residual overage is inherent to this fallback path having zero real source content, a bigger separate authoring task not done this session, owner to decide if wanted). 2026-08-04 earlier same day: fixed the original repeating-audio/text bug — owner reported the long video repeating identical lines. Root cause: expand_slide_content() padded any under-80-word slide from a pool of only 2 generic filler paragraphs with no cross-call tracking; a 22-slide video routinely needs 5-10+ slides padded, guaranteeing the same paragraph/TTS audio repeated across the same video. Fixed: pool expanded to 8 varied paragraphs/language, a shared used set threaded through every padding callsite in one video so no filler repeats until the whole pool is exhausted. Same bug class as human_touch.py v2.4/v2.5; v1.3 weekly dup-guard: skips if Week N already on channel) | | generate_reel.py | v2.8 (2026-10-23: series renamed ‘Amit Ki Baat’ -> “Trader’s Notebook” (owner: odd name, don’t use my name); evergreen titles with no date/Nifty level; same rename in upload_youtube.py fallback + daily_reel.yml; morning badge now just ‘MORNING BRIEF’. v2.7 (2026-08-04: fixed 2 spots — “✦ ai360trading.in”/”📱 t.me/ai360trading” watermark text — where emoji/dingbat characters rendered as broken tofu-box glyphs even on the real GitHub Actions runner, confirmed via a real temporary CI workflow that ran the actual font-resolution + render code and downloaded the output image. DejaVu Sans (the font this repo’s get_font() correctly resolves to on Linux) has zero emoji glyph coverage and no emoji font package is installed on the runner — this was a real, previously-unknown live bug affecting every published frame using these lines, not a local-only artifact. Fix: emoji removed from these two draw.text() strings only; left untouched every emoji in build_youtube_title()/description functions further down, which are plain YouTube metadata text rendered by YouTube’s own app, unrelated to this bug; 2026-07-26: owner asked for a “younger” voice — Edge TTS has only ONE Hindi male voice (Madhur) so there’s no alternate to switch to within the ₹0/month constraint; added VOICE_PITCH="+15Hz" (tune after listening, not a calibrated constant) as the ₹0-compliant lever. Also: draw_stat_block() gained a real PIL-polygon triangle arrow next to the UP/DOWN pill instead of text alone — a real graphic reads at thumbnail size far better, same proven finance-content CTR technique added to generate_reel_morning.py/generate_shorts.py in the same pass; bullish/bearish accent colors also pushed more saturated for a stronger pop. Rendered and visually verified test thumbnails before shipping; v2.6 2026-07-24: last ZENO artifact removed — TTS voice was still hi-IN-SwaraNeural (the “wise female” voice ZENO was originally cast with) even after v2.5 removed the character from the script/visuals; switched to hi-IN-MadhurNeural (male) to match Amit’s own persona and Short 2’s existing voice; also added the same Ken Burns 1.0→1.08 zoom motion fix already proven in generate_shorts.py v3.11 — the reel’s frame was 100% static for the full 45-60s, fully fail-open; v2.5 2026-07-22: ZENO cartoon persona retired — owner (“Amit”) said it doesn’t fit serious traders; prompt now writes in Amit’s own voice, first-person, no kid character; added fetch_eod_snapshot() (real NIFTY close, yfinance, fail-open) so the evening reel is grounded in an actual number instead of 100% generic wisdom; thumbnail/frame character-compositing replaced with a live NIFTY stat block; “ZENO Ki Baat” branding → “Amit Ki Baat” across titles/descriptions/hashtags/badge) | | generate_reel_morning.py | v2.9 (2026-10-23: fetch_prices() switched from yfinance fast_info.get() (returns None on yfinance 1.4.x → every price 0, thumbnail ‘Nifty: 0’) to daily-history last-2-closes with fast_info[…] fallback. v2.8 (2026-08-04: same real emoji-tofu-box fix as generate_reel.py/generate_shorts.py/upload_youtube.py/generate_education.py this date — 2 spots fixed: the “AMIT’S MORNING BRIEF” thumbnail badge (☀️ removed) and a NEUTRAL-sentiment fallback label (⚖️ removed) that the 2026-07-26 real-triangle-graphic fix below had missed for BULLISH/BEARISH but not NEUTRAL. Confirmed via a real GitHub Actions test run + downloaded output image that emoji genuinely renders as tofu boxes on the actual ubuntu-latest runner (DejaVu Sans has no emoji glyphs, no emoji font package installed) — a real live bug, not a local artifact; 2026-07-26: same VOICE_PITCH="+15Hz" add as generate_reel.py v2.7 — applied only to the Hindi/Madhur path, not the English/Jenny fallback. Also added the same triangle-arrow icon to the main sentiment badge (BULLISH/BEARISH only, NEUTRAL gets none) and all 3 ticker rows (Crude/Gold/Bitcoin), replacing text-only “up”/”down”; bullish/bearish colors pushed more saturated. Rendered and visually verified a test thumbnail (all 4 arrows correct direction/color) before shipping; v2.7 2026-07-24: same voice fix as generate_reel.py v2.6 — VOICE_HI was still hi-IN-SwaraNeural, switched to hi-IN-MadhurNeural so every Hindi-voiced format speaks as one consistent person; added the same Ken Burns zoom motion fix (wraps the existing per-line frame VideoClip in a slow 1.0→1.08 zoom), fully fail-open; v2.6 2026-07-22: same ZENO retirement — this file already pulled 100% real data (Nifty/Crude/Gold/BTC/news) but still pasted a cartoon over it on every frame, not just the thumbnail; character removed from build_thumbnail()/create_frame()/create_morning_video(), replaced with a live Crude/Gold/Bitcoin ticker + NIFTY level now shown on every frame; “MORNING BRIEF” badge → “AMIT’S MORNING BRIEF”) | | generate_shorts.py | v3.13 (2026-08-04: same real emoji-tofu-box fix, largest scope of the 5 affected files — 14 spots across both Short 2/3 thumbnails and frames (badge text, market-data label tuples like “🇮🇳 NIFTY”/”🥇 GOLD” that feed draw.text() through a data tuple two functions away from the literal string, sentiment icons, footer branding). Confirmed via a real GitHub Actions CI run that emoji renders as tofu boxes even in production. Left untouched every emoji inside build_title_short2/3(), build_desc(), build_fb_caption(), build_ig_caption_*() — all plain YouTube/Facebook/Instagram metadata text, unrelated to this Pillow/DejaVu bug; 2026-07-26: same VOICE_PITCH="+15Hz" add, applied only to VOICE_SHORT2 (Madhur/Hindi), not VOICE_SHORT3 (Neerja/English). Also added the same triangle-arrow icon to build_short_thumbnail()’s category badge — direction inferred from the accent color the caller already picked (BULL_GREEN/BEAR_RED), no arrow for GOLD/neutral. Rendered and visually verified a test thumbnail before shipping; v3.12 2026-07-19: hook prompts prefer a REAL data number as first word; v3.11 retention pack: 20-25s payoff-at-end scripts + Ken Burns motion + 2s CTA) | | generate_articles.py | current (2026-10-23: ONE article/day (was 2, each pillar every 8 days); 3 new evergreen formats how_to_guide/myth_vs_fact/book_idea (weekend/holiday always evergreen, market days every 4th day); per-country section blocks removed -> India-first, no invented stats/quotes; book ideas come from data/book_refs.json in our own words, credited; 2026-10-23 earlier: per-article chart share image + book_links ‘Further reading’ block. 2026-07-26 affiliate-link audit: AFFILIATE_LINKS['broker']['india']/['investing']['india'] had their OWN separate, hardcoded, no-referral-code Zerodha URL (https://zerodha.com/open-account/) used to prompt the AI to insert natural in-body broker mentions — while the bottom-of-article CTA box (money_funnel.article_cta_html()) always correctly used the referral-coded one (?c=YS3694). Confirmed live in every article since April 2026, including today’s: every article’s inline body text sent readers to Zerodha WITHOUT the referral code while the CTA box below it had the right one. Fixed: both now import and reuse money_funnel.LINKS['zerodha'] directly (fail-open to the old bare URL if the import fails) so they structurally cannot drift apart again — same “single source of truth” rule money_funnel.py’s own docstring already states. usa/uk/insurance/loans affiliate links are genuinely separate programs with no money_funnel equivalent, left untouched. 2026-07-26 full-system audit (earlier same day): the article’s AI-disclosure footer was a separate hardcoded string, never routed through human_touch.ai_disclosure() — a real violation of the “human_touch.py on ALL content, never bypass” rule, on the site’s highest-traffic surface. Now calls human_touch.ai_disclosure("en") with a fail-open fallback to the old hardcoded string if the import fails; the “Not investment advice” legal line stays appended outside the disclosure call. Money-funnel CTA wiring was already correct, only the disclosure text was affected. 2026-07-20: meta_description/schema_json now run through clean_ai_title() too — was only applied to the title, so a raw “SandP 500” LLM artifact could still ship in the meta tag/JSON-LD; also now calls performance_stats.write_data_json() alongside the existing article perf block; 2026-07-16 credibility pack: price carry-forward cache _data/prices_cache.json + honest Fear&Greed + filler-phrase ban + number-honesty rule 14 + internal-link dedup; 2026-07-13 phrase-cooldown + perf block) | | performance_stats.py | v1.2 (2026-10-18: adds since + per-strategy by_type (Swing & positional / Intraday (cash) / Options alerts: trades, win rate, avg win/loss, net P/L) to _data/performance.json; membership.md ledger card now shows a by-strategy table so the public ledger is honest per strategy, not one blended number. v1.1 (2026-07-20: write_data_json() publishes the same honest ledger stats to _data/performance.json — Jekyll site.data.performance — so the membership page can show the real, live win-rate/ledger table, not just generated articles; fail-open carry-forward, same doctrine as prices_cache.json; v1.0 2026-07-13 — History ledger → article performance block) | | publish_swing_board.py | v1.0 (2026-07-23: NEW — reads AlertLog live, writes public-safe columns only (Symbol/Trade Type/Strategy/Breakout Stage/Trade Status/Days in Trade — never SL/Target/Entry/Position Size) to _data/swing_board.json for the swing-dashboard page’s free teaser table; same fail-open/Jekyll-_data doctrine as performance_stats.py; replaces the old SwingPublic Google Sheets “Publish to web” embed that broke 2026-07-23 with no API to fix; run by swing_board.yml’s */15 cron (now a backup only — see swing_board_session.yml, 2026-07-28) plus swing_board_session.yml’s dense internal loop) | | trending_keywords.py | v1.2 (2026-07-28: _OFFTOPIC exclusion set added — a homonym collision, not the v1.1 prefix bug: “Nifty” is also a Hazbin Hotel character name, so seed “nifty” legitimately whole-word-matched junk like “nifty vs velvette”/”nifty hazbin hotel”/”nifty and baxter” (confirmed live in _data/trending.json). All ranked below the top-15 generate_articles.py reads via get_trending("finance", 15) — zero live-article impact so far, but the same “ranks lower today, could rank higher tomorrow” fragility that already caused the v1.1 “ipod” incident. Live cache file cleaned of the 6 junk terms same session; v1.1 2026-07-20: whole-word seed filter in _collect() — YouTube autocomplete for seed “ipo” was returning “ipod”-class junk via shared prefix, not word-match; this had already published 2 live articles with “ipod” in the title/slug, both removed) | | _(HerooQuest kids pipeline removed 2026-07-19 — generate_kids_video.py, kids_content_calendar.py, upload_kids_youtube.py, kids-daily.yml all deleted; channel retired as it cannot earn — see SESSION.md) |

Upload & Distribution

| File | Version | |—|—| | upload_youtube.py | v2.4 (2026-08-04: same real emoji-tofu-box fix — 2 spots in the ZENO-retirement fallback thumbnail path (a sentiment badge dict and the “☀️ 7:00 AM IST” time badge). This fallback only fires if the reel scripts didn’t produce a thumbnail, but the bug was real regardless — confirmed via an actual GitHub Actions test run; 2026-07-22: the fallback thumbnail/metadata builder — only fires if the reel script itself failed to produce one — still pasted the ZENO cartoon and “ZENO Ki Baat” branding; owner retired ZENO for a serious-trader identity and this dead-path fallback was the one spot that got missed. Now matches: live NIFTY stat block, “Amit Ki Baat”. generate_reel.py’s save_meta() now also writes nifty_level/nifty_pct into meta.json so this fallback has real data) | | upload_facebook.py | v2.10 (2026-07-26 full-system audit: the v2.9 “fixed to read live version” claim was itself another hardcoded string literal — the file never had an actual VERSION constant, so both runtime print() banners were one hand-edit away from going stale again. Added a real VERSION constant, both banners now read it — can’t silently drift again. v2.9 2026-07-24: full-system-audit cleanup — CLI –help text no longer says “ZENO” for the evening-reel prefix; runtime print() version stamp was hardcoded “v2.6” and silently stale through v2.7/v2.8. v2.8 2026-07-23: build_caption() uses human_touch.fb_caption_opener()/fb_hashtags() instead of a fixed f-string per mode — see human_touch.py v2.4; v2.7 2026-07-19 — kids branch removed; ZENO/morning/education/market paths unchanged) |


STRICT RULES — NO EXCEPTIONS

Rule A — Read Files Directly

Rule B — Only State What You Can See

Rule C — Always Provide Complete Files

Rule D — Verify Before Claiming Fixed

Rule E — Ask, Don’t Assume

Rule F — Update .internal-ops.md After Every Change

Rule G — Never Break What Works


🔒 NEVER CHANGE — OWNER-VALIDATED DECISIONS

(Each line exists because of real money, real data, or an explicit owner decision. Do NOT “improve” these. If a change seems needed, present evidence and get owner approval FIRST. Full history in .internal-ops.md + memory.)

Trading — hard rules

| Rule | Why (evidence) | |—|—| | BROKER_MODE = "PAPER" — never connect Dhan API / place real orders | Phase 4 only, owner must explicitly start it | | Do NOT add DHAN_* secrets (API key/secret/client ID/PIN/TOTP) to GitHub until Phase 4 is actually being built | 2026-07-20 security review: 5 unused Dhan secrets had sat in GitHub Actions secrets since Jan 2026 with zero code referencing them — live real-brokerage login credentials exposed for 6+ months for no reason. Deleted. Add fresh ones (and rotate PIN/TOTP on Dhan’s side first) only when Phase 4 work actually starts | | Buy-side only — never build/propose shorting or PE buying | Owner DECLINED sell-side 2026-06-05; bearish regime → options SKIP (option_intelligence v1.1) | | RS ≥ 5 gate (bot Filter 7 + appscript GATE 3) — never lower | Data-validated 2026-07-15: ALL 6 target winners pass, 2/5 losers correctly blocked | | RSI hot-leader exception exactly as calibrated — bullish + RSI 65–75 + stock “up on day” (now day_pct > -0.3%, a noise floor added 2026-07-21 v15.29 — was > 0 exactly) = allowed; >75 hard block; fail-CLOSED without day data | 13-trade study 2026-07-15: no loser ever entered overbought; NYKAA-class winners were being refused. Don’t revert to hard-65, don’t raise above 75. The 0.3% floor is itself now locked too — evidence-based (3.5-week log audit: RSI was 45% of all entry blocks, 62% of those sat in the 65-75 window with a bullish regime, killed by single-tick day_pct noise) — don’t raise the floor further without new evidence, don’t remove it | | VOL gate = 1.5× PACE (time-fair since v15.21/v1.1) — the 1.5× bar stays | Bar is deliberate (institutional footprint); only the measurement was made time-fair. Reversal-risk/ranking uses RAW relvol on purpose | | AppScript volume gates = time-fair PACE with EOD-identical bars (v15.24: GATE 7 2.2×, BASE 1.4×, MOM/CASH 3.0×) — never revert to raw intraday readings, never change the bars without data | Volume_vs_Avg % is diff-form (+50 = 1.5×, owner screenshot v15.13); raw partial-day readings vs full-day averages structurally starved every intraday gate (the same bug fixed Python-side 07-16). Blank volume cells stay BLOCKED (volHasData) | | _ENTRY_ memory keys = swing entries ONLY (v15.23) — cash writes only _CASH_ | The 3/day swing budget counter counts _ENTRY_; cash writing it too meant cash trades ate swing slots (v15.9 BUG-E’s filter never worked) | | Earnings gate reads BotMemory ROWS via bm_data (option_intelligence v1.2) — never revert to scanning the _RUNTIME_MEM string | fetch_earnings.py writes EARNINGS_* as sheet rows; the string scan matched nothing → CE suggestions possible the day before results | | ONE shared strike grid (<100→1, <250→5, <500→10, <1000→20, <2000→50, ≥2000→100) in option_intelligence.strike_step + bot CE fallback + appscript _getStrikeInterval — change all three together or none | 2026-07-17: three divergent tables meant night vs entry alerts could name strikes on different ladders; the coarser grid is used because a coarse multiple always EXISTS when NSE’s finer real spacing divides it (never alert a non-tradeable strike) | | appscript.gs CONFIG.CASH_ENTRY_WINDOW and trading_bot.py CASH_ENTRY_WINDOW_END must stay the same clock time — change both together or neither | 2026-07-26 full-system audit: appscript.gs extended its scan-side window 10:30→13:30 (v15.29/30) to catch afternoon-building gap movers, but trading_bot.py’s promotion-side cutoff was left at 10:30 — appscript.gs queued afternoon cash candidates that trading_bot.py then silently refused to ever promote, a real candidate-starvation bug live for ~2 days before caught. Same failure class as the strike-grid rule above: one engine scans/writes, the other promotes/reads, and any window/threshold that gates both sides must be edited in both files in the same commit | | CASH_FORCE_EXIT_HOUR = (15, 10) (trading_bot.py v15.35) — Cash Intraday’s same-day force-exit clock. Don’t revert to (15,0) or push materially later without re-checking the evidence below | 2026-08-05: owner asked for a full History-backed decision on Cash Intraday improvements. All 10 closed Cash Intraday trades to date (7W/3L, net +₹1,135) exited via THIS clock 10/10 times — never the +12% target, never the -3% SL — and every winner’s exit price sat within ~1% of its Max-Price-Seen, proving TSL_PARAMS["CASH"] already protects gains on its own. The old 15:00 value was also never revisited when NSE moved the real close 15:30→15:40 (2026-08-03). Moved to 15:10 to restore the exact same ~30-min buffer before the (now real) close — gives already-protected trades a bit more runway, doesn’t loosen risk control. CASH_MIN_PCT_CHANGE/CASH_TARGET_PCT/CASH_SL_PCT deliberately left untouched — 10 trades over 9 days is too small a sample to recalibrate an entry threshold without overfitting to noise. Re-evaluate once the sample is bigger (this table + .internal-ops.md 2026-08-05 entry has the full 10-trade breakdown) | | MIN_SL_ATR_MULT = 0.75 — DMA-anchored SLs must leave ≥0.75×ATR room | BHARATFORG 07-16: DMA snap gave a 1.07% stop on a 2-4 week trade + fake RR 9.6 | | Owner’s risk rule: tight SL, target ≥5% reachability-aware (ATR%≥1.3 only), trail, option loss hard-capped 20%, option exit stock-anchored | v15.18 design; do NOT blindly hard-floor targets at 5% for low-vol names. Since bot v15.25 the PAPER LEDGER enforces the option rules too (20% est-loss cap + expiry-week backstop + v15.26 2-trading-day exit) — never revert options to generic stock time-stop/hard-loss monitoring | | OPTION_LOSS_CAP_PCT = 20 — do NOT raise/loosen based on the 2026-08-10 PRESTIGE/RADICO finding alone | 2026-08-10: owner reported both were real-money trades that hit the cap on an ordinary ~2% same-day stock dip, and both stocks recovered within days after. Two real examples is not enough evidence to touch a deliberately-set risk ceiling — could just as easily mean the cap is correctly capping trades that would’ve gotten worse on a different day. 2026-10-23 re-checked: kept at 20; review once OptionCapWatch has >=8 scored rows with a majority recovered (INDHOTEL est -31% was ~10x leverage on a -3.1% stock move; cap trips at ~2% stock drop). Built OptionCapWatch (trading_bot.py v15.36) to gather real evidence instead of reacting to two hard days — revisit only once it has a meaningful scored sample (same “wait for real evidence” doctrine as ShadowLedger’s volume-gate question) | | Live option entries need ALL of: >=15d to expiry, HV-20<=35%, NIFTY PCR 0.6-1.4 (trading_bot.py option_entry_gate, v15.39) — do not loosen without new evidence; PCR comes ONLY from the official end-of-day F&O bhavcopy file (fetch_bhavcopy.py v1.1), never from the cookie-protected option-chain API | 2026-10-23 owner decision after the History review: 19 option alerts had no stock edge (entry-day close avg -0.5%, day+2 +0.15%, 32% win, -Rs 1,708). Cheap + low-decay + sane PCR is the owner’s filter; forward results decide if it works. Still buy-side CE only, current expiry | | Options Alert requires isBreakoutStage (appscript.gs v15.37) — do not remove this check to “loosen” the option candidate pool | 2026-08-11 owner rule, shipped the night before real-money trading began: options only on momentum-confirmed stocks (retest-of-a-confirmed-breakout stays allowed, unconfirmed/early-stage candidates don’t). Verified against live data before shipping — didn’t block any real candidate seen that day, only closes the gap case. Removing this reopens exactly the gap the owner asked to close | | Options Alert’s fixed 1:1.9 target ratio — do NOT raise to 1:3-1:5 without new evidence | 2026-08-11: owner asked for a bigger target. Pulled all 13 real Options Alert trades from History and computed the real math: the CURRENT 1:1.9 target was only reached in 1/13 (8%) of real trades — every other trade (win or loss) was decided by the 2-day forced time exit or the loss cap, never by the stock reaching the target price. At 1:3-1:5 distances, 0/13 real trades would ever have reached it. The target ratio is not the real lever on these trades’ P/L — raising it would not have changed a single real outcome in this sample. Don’t revisit without a materially different, larger sample showing otherwise | | No real-time NSE option-chain liquidity (OI/volume) checking — do NOT add one via nsepython/nsetools/scraping | 2026-08-11: researched every free option-chain data source; all use the same fragile session-cookie-scraping pattern this project already explicitly avoids (see fetch_dividends.py’s own docstring, citing the 2026-05-27 holiday-list outage as the same fragility class). Wiring a scraper into LIVE entry decisions (not an informational-only column) is a bigger risk than that prior mistake. Existing ITM/current-expiry strike selection already structurally favors the most liquid strikes by construction. Only revisit this via a real paid broker/data-vendor API, and only alongside Phase 4 broker integration — never via free scraping bolted onto live entries | | OPTIONS = INTRADAY / MAX 1-2 DAY HOLDS, CURRENT EXPIRY (owner rule 2026-07-18) — expiry picker: current month, ≤5d rollover → next month, NEVER month+2; swing/positional moves are traded in CASH/MTF, never as multi-week option holds; small/mid caps CASH only | Owner’s own trading structure: liquidity lives in the current expiry (the old 40-day minimum put a 17-Jul signal on the dead Sep chain — no volume, ~20% spread); theta eats long option holds while the stock consolidates. Never restore MIN_DAYS-style far-expiry pickers or multi-week option hold wording | | Alert language = easy Hinglish + self-contained instructions (v15.26): every entry/option alert states HOW to trade it (CASH/MTF/intraday), “Entry valid jab tak price SL ke upar hai”, and which TradingView chart to check (DAILY swing / 15m cash+option timing / WEEKLY positional) | Owner + family act on these directly; a signal without “how” caused the MOTILALOFS Jul 1050 CE deviation (wrong strike, wrong expiry, wrong hold) | | Telegram messages stay COMPACT (watch plan, late-alert, option-chase, HTF lines) | Owner 2026-10-19: members read the signal and level; long text costs time. Watch plan = 3 lines/stock, notes = 1 short line. Keep easy-Hinglish, ‘Entry valid jab tak price SL ke upar hai’ and TradingView line as-is | | Empty AlertLog on a red/quiet day is CORRECT behavior, not a bug | NIFTY_MIN_PCT_BULLISH −0.30 + honest gates; 07-16 verified: 0 entries was right | | Fail-open for data feeds, fail-closed only where calibrated | free-forever_self_repair: a dead feed must never kill the bot; the RSI exception is deliberately fail-CLOSED | | Nifty200 “ATR (14)” col = Python-fed true ATR (fetch_rs v2.0) — never restore a GOOGLEFINANCE formula there | Audit 2026-07-17: old formula returned ONE day’s high−low (ABB +60% off, IEX −26%) and fed every SL/target + option signal in both engines | | Nifty200 CMP/%Change/Live_Volume (fetch_live_prices.py, 5-min) + 20/50/200DMA/52_Weeks_Low/High/Avg_Volume(20D)/Pivot_Resistance/VCP Status/Days Since Low (fetch_rs.py v2.1, 2x/day) = Python-fed — never restore a GOOGLEFINANCE formula in any of these 12 columns | 2026-07-24 full-system audit: ~25 of 37 Nifty200 columns were still live GOOGLEFINANCE formulas, including CMP itself — the single most foundational cell (IF(C3="","",...) gates nearly every other formula), so a GOOGLEFINANCE outage on CMP alone would zero the WHOLE scoring engine, same failure mode as the 2026-06-02 RS outage. Fast lane (CMP/%Change/live-volume) needs true 5-min freshness — both trading_bot.py and appscript.gs poll every 5 min during market hours; slow lane (DMAs/52wk/volume-avg/pivot/VCP/days-since-low) only needs 2x/day like RS/ATR already had. Verified before cutover: CMP/%Change tracked live GOOGLEFINANCE within mean 0.13%/max ~1% across all 200 symbols; 52-week hi/lo and Pivot_Resistance matched GOOGLEFINANCE exactly in spot checks. Column O (Volume_vs_Avg %) keeps its formula but now reads the new Live_Volume helper column (AL) instead of calling GOOGLEFINANCE(A,”volume”) inline — never re-point it back | | Nifty200 row 2 (the NIFTY50 index summary row) is INCLUDED in the above rule, not exempt — its CMP/%Change (fetch_live_prices.py) and 20_DMA (fetch_rs.py) must stay Python-fed from ^NSEI too, never re-skipped back to GOOGLEFINANCE or left out of either script’s target loop | 2026-07-26 sheet/formula audit: the 2026-07-24 migration above covered all 200 stock rows but both scripts explicitly skipped the one row containing “NIFTY” in the symbol — leaving row 2 on its original live GOOGLEFINANCE formula, unnoticed, for CMP/%Change/20_DMA. This was worse than a normal missed row: get_market_regime() (trading_bot.py) reads row 2’s CMP/%Change/20DMA DIRECTLY with no yfinance fallback, deciding bull/bear for the entire bot every tick — so the single-point-of-failure the 07-24 migration was built to close was still open for the one row that matters most. Fixed: fetch_live_prices.py v1.1 and fetch_rs.py v2.2 now feed row 2 too, using ^NSEI (matching get_nifty_pct_change()’s own existing fallback and fetch_rs.py’s BENCHMARKS list) instead of skipping it | | fetch_rs.yml keeps its OWN concurrency group — never share trading-bot group | Sharing it froze RS 07-13→07-17 (queued runs kicked daily by the session loop); sheet writes are atomic, sharing was never needed | | Option signals gate on Nifty200 col AJ “Options” (N50/YES/”” from NSE’s official F&O file, fetch_index_meta weekly) — never gate on hardcoded stock lists | 2026-07-17: hardcoded F&O list still contained IRCTC (derivatives removed by SEBI purge), TATAMOTORS (demerged), ZOMATO (renamed) — impossible/dead option alerts | | Nifty200 col B = 18 NSE macro sectors; col AK N200 = YES/EX membership — never revert to fine-grained custom labels | 62 of the old 100 labels held ONE stock → auto “rank 1 leader” bonuses, dead momentum-sector detection, biased “Strongest:” line |

Money & platform

| Rule | Why | |—|—| | ₹0/month forever — no paid services without explicit owner approval | 6-family-member livelihood; free tiers only | | human_touch.py on ALL content — never bypass | Content authenticity pipeline | | AI use is DISCLOSED, never evaded — never build AI-detection evasion | FB flag 2026-06-07 was informational; disclosure + quality is the compliant path | | ai_client.py for every AI call — actual production chain is Groq → Gemini → Templates; Claude/OpenAI are coded but gated behind ALLOW_PAID_AI, never set (corrected 2026-07-28, this row had the same “implies Claude/OpenAI run live” framing already fixed elsewhere in this file/requirements.txt/.internal-ops.md — missed here until now) | Never call AI APIs directly | | money_funnel.py = single source of broker/referral links in content | | | Any new India broker/investing affiliate link (in generate_articles.py’s AFFILIATE_LINKS or anywhere else) must reuse money_funnel.LINKS['zerodha'], never a separate hardcoded URL | 2026-07-26 affiliate-link audit: generate_articles.py had its own separate, no-referral-code Zerodha default for in-body AI-inserted mentions, silently diverging from the correct referral-coded link used in the same article’s CTA box — live in every article since April 2026, real lost referral income. Fixed by importing money_funnel.LINKS directly instead of duplicating the string | | Repo stays PUBLIC (GitHub Actions unlimited); .internal-ops.md/SESSION.md/CHANGELOG.md stay PC-LOCAL (gitignored) | Owner decision 2026-05-30 |

Operational invariants

| Rule | Why | |—|—| | Never put emoji characters inside a Pillow draw.text() call (or a variable that flows into one) across generate_education.py/generate_reel.py/generate_reel_morning.py/generate_shorts.py/upload_youtube.py — plain text/Unicode punctuation only | 2026-08-04: confirmed via a real temporary GitHub Actions workflow (ran the actual font-resolution + render code on ubuntu-latest, downloaded the output image) that emoji renders as broken tofu-box glyphs even in production — DejaVu Sans (the font get_font() correctly resolves to on the real CI) has zero emoji glyph coverage and no emoji font package is installed on the runner. Em-dashes and other plain Unicode punctuation render fine — only emoji is affected. Fixed 22 real occurrences across 5 files same day. This does NOT apply to YouTube/Facebook/Instagram video titles/descriptions/captions — those are plain metadata strings rendered by each platform’s own app using its own real emoji font support, completely unaffected; emoji there is fine and untouched | | Owner trades MANUALLY with real money off the Telegram alerts | Alert quality = family income. Test + verify BEFORE every deploy; subscriber-facing wording must be honest (no fake VIX, no false-certain regime verdicts, no stale version stamps) | | AppScript deploys via .\deploy_appscript.ps1 (clasp) — never manual paste; bump CONFIG.VERSION every release | It is the single source for all message version stamps | | .bat/.cmd/.ps1 files stay CRLF (.gitattributes enforces) | smartsync.bat was silently broken for months by LF endings | | NSE holidays only from the official NSE list — never guess/hardcode from memory | 2026-05-27 full outage from a wrong hardcoded list | | Never ask the owner for chart screenshots for calibration — use yfinance | Owner directive 2026-07-15; charts only when HE offers them | | T2 cell (AlertLog col T row 2) = automation master switch | YES = enabled | | A same-day code fix pushed during market hours will NOT take effect until the next fresh session, for ANY of the 3 session-loop workflows — trading_bot_session.yml, fetch_live_prices_session.yml, swing_board_session.yml (2026-07-28) each check out code once, then loop internally via shell while+sleep for hours; GitHub Actions never re-checks-out code mid-job. Verified 2026-07-28 for trading_bot_session.yml: the day’s session had already checked out at 11:43 IST, a real TSL-spam fix landed at 12:43 IST, and every tick for the rest of that session (through 15:01 IST) kept running the pre-fix code. If a same-day fix genuinely must take effect today, manually cancel + re-dispatch the session workflow (or its */5/*/15 backup cron — each of ITS invocations is a fresh checkout) to force a new checkout; otherwise it silently waits for the next session | Discovered while verifying the v15.32 TSL fix — it looked “unfixed” in live logs for hours after the commit landed, which was a checkout-timing artifact, not a bad fix | | fetch_live_prices.yml/swing_board.yml’s */5/*/15 crons are throttled by GitHub to ~3-5 runs/day (verified via gh run list, 2026-07-23 had ZERO fetch_live_prices runs all day) — never rely on them alone for freshness; the real density comes from fetch_live_prices_session.yml/swing_board_session.yml’s internal loops — same platform limitation already documented for trading_bot.yml/trading_bot_session.yml. Any NEW high-frequency (*/5, */15) cron added to this repo should be assumed throttled the same way and paired with a session-loop workaround from day one, not discovered later via missing data | Live evidence 2026-07-28: fetch_live_prices.yml feeds CMP — the single most foundational Nifty200 cell with no fallback — and was going stale for 1.5-2.5hr stretches during market hours; swing_board.yml feeds a public page that claims “auto-refreshes every 5 min” while actually refreshing ~3x/day | | The 3 session-loop workflows’ morning/afternoon trigger minutes must stay off round :00/:15/:30/:45 boundaries and staggered a few minutes apart from each other — trading_bot_session.yml/fetch_live_prices_session.yml/swing_board_session.yml all fired at the identical round minutes (08:00/08:30/09:15/11:55/12:30/13:30 IST) until 2026-07-31. gh run list showed EVERY morning that week starting 2-3+ hours late (first tick ~10:53-11:47 IST daily, not just the rare 2026-06-02 incident) — and it wasn’t just these 3: totally unrelated workflows with unique minutes (Daily Morning Reel 07:00 IST, System Watchdog 08:05 IST) were also delayed 3+ hours the same morning, so this is GitHub-scheduler-wide, not just self-collision between these 3. Fixed by shifting all 18 trigger lines off-grid (GitHub’s own documented advice) and staggering the 3 workflows ~5-6 min apart. UPDATE 2026-08-03: this fix is now confirmed NOT sufficient, not just “unproven.” On a big up-day (NIFTY +390pts/+1.6%, many stocks +3-7%), gh run list showed trading_bot_session.yml’s first real dispatch of the day at 05:58:01 UTC = 11:28 IST — none of its 3 off-grid morning triggers (08:02/08:32/09:17 IST) actually ran until then, a ~2h11-3h26m delay past their own scheduled minutes. Directly confirmed against AlertLog: the day’s first trade (NSE:LENSKART) is timestamped 2026-08-03 11:28:30 — literally the same minute as the bot’s delayed first scan, i.e. the system was blind from market open (09:15 IST) until 11:28 IST. Off-grid minutes alone did not fix it — the CLAUDE.md’s own prior caveat (“root cause may be total scheduled-workflow volume across the whole repo”) looks like the real explanation: this repo runs 29 workflow files and logged 73+ Actions runs before 21:09 IST on this one day alone (CodeQL + pages-build-deployment fire on nearly every commit, on top of all the scheduled crons) — plausibly enough queue pressure on GitHub’s free-tier scheduler to still bump scheduled dispatch by hours regardless of which minute is requested. UPDATE 2026-08-04: partial fix shipped (swing-board push slowdown, 2026-08-03), confirmed only marginally effective — first scan moved from 11:28 IST to 11:02 IST, a 26min improvement, still ~1h47m after market open. Total push-triggered runs stayed at the same order of magnitude (58 by 20:21 IST vs 73+ the day before). Migrated CodeQL to Advanced setup with paths-ignore the same day (cuts its ~8 runs/day of content-only-commit noise — see the CodeQL/Pages rows above) as the next capacity-reduction step; Pages’ matching ~8 runs/day deliberately left untouched after finding it would break 4 live data-driven pages (see the Pages row above). Also discovered the same day that a one-off cloud “morning safety-check” routine set up 2026-08-03 night to force-dispatch the bot if still silent by 08:35 IST did NOT actually intervene (zero workflow_dispatch runs found despite the bot genuinely being silent at check time) — cause unconfirmed (execution transcript not retrievable), likely a permissions gap; this safety-net approach is retired, not relied on again. UPDATE 2026-08-04 (later): investigated reducing cron density itself across all 29 workflow files (49 total - cron: lines) — found only one additional safe cut (.keepalive, a daily necessary-but-trivial timestamp commit that was still triggering a full CodeQL scan for zero code change, added to CodeQL’s paths-ignore), no further safe reduction available. Every other cron line is either (a) one of the 18 trading-critical session-loop retry/backup triggers (trading_bot_session.yml/fetch_live_prices_session.yml/swing_board_session.yml’s own 6 starts each, plus the 3 */5/*/15 backup crons) — removing any of these directly increases the risk of exactly the blind-window problem this whole investigation exists to fix, so none were touched — or (b) already-minimal daily/weekly content and data-feed jobs with no real redundancy to cut (confirmed via grep -n "git push\|git commit" that auto_heal.yml/watchdog.yml are read-only monitoring and don’t even contribute to the CodeQL/Pages push-volume problem in the first place). Conclusion: no further safe infra-side fix is available right now without either accepting more risk to trading reliability or reopening the Pages decision (already rejected on evidence). Remaining path forward is observing whether the CodeQL cut compounds with the swing-board slowdown over the next several trading days, not another unilateral schedule change. | Live evidence 2026-07-31: market open (09:15 IST) to ~11 AM was running on the throttled */5 backup cron ALONE every single day — the backup cron’s first tick that morning was also delayed to 11:22 IST, meaning a genuine ~2hr blind window with zero dense SL/target monitoring, daily, for at least a week | | CodeQL runs via Advanced setup (.github/workflows/codeql.yml), Default setup is OFF (state: not-configured) — never re-enable Default setup while codeql.yml exists | 2026-08-04: migrated Default→Advanced to add paths-ignore (content-only commits like swing-board/daily-report json pushes were triggering a full CodeQL scan for zero reason — confirmed via git show --stat that those commits touch only _data/**/_posts/**, never code). Tested this exact conflict directly: with Default setup still active, codeql.yml’s SARIF upload failed on all 3 languages with "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled" — a hard GitHub-side mutual exclusion, not a config bug. If Default setup is ever re-enabled (e.g. via GitHub Settings UI) while this workflow file still exists, every future CodeQL run will fail the same way | | GitHub Pages stays on "build_type":"legacy" — do NOT migrate to an Actions-based deploy with a _data/**-excluding paths-ignore, even though this was considered as a GitHub-Actions-scheduler-load fix | 2026-08-04: confirmed via direct grep that swing-dashboard.html, membership.md, positional-picks.html, and tutorial-videos.html all read _data/*.json via Liquid (``) resolved at Jekyll BUILD time, not client-side. Excluding _data/** pushes from a Pages rebuild trigger — the natural way to cut its push-triggered run volume — would silently freeze all 4 of those live pages’ data forever, including membership’s real win-rate stats (direct revenue-page regression). Since nearly every push in this repo is either genuine code or genuine _data/*.json content that structurally REQUIRES a rebuild, there is no safe subset of pushes to filter here; this option was evaluated and rejected on evidence, not merely deferred | | All 3 session-loop workflows share their concurrency group with their own backup cron, and BOTH sides must use cancel-in-progress: false — if the short backup cron used cancel-in-progress: true while sharing a group with the long session loop, a stray backup-cron trigger would kill the session loop mid-tick instead of queueing harmlessly behind it. fetch_live_prices.yml was flipped true→false for exactly this reason 2026-07-28 when fetch_live_prices_session.yml was added; swing_board.yml had no concurrency block at all before the same date (meaning it and swing_board_session.yml could have run concurrently and raced on the git push) | Same failure mode as sharing fetch-rs’s group with trading-bot did in July — the fix pattern is now standard: new session-loop workflow + existing backup cron always share one group, both cancel-in-progress: false | | trading_bot_session.yml: timeout-minutes: 355 + loop self-stops after MAX_RUN_MIN=340 — never lower the timeout back to 235, never remove the cap | 2026-10-18: the cron-job.org dispatch (09:30 IST) ran the loop to END=15:50 but the old 235-min job timeout force-CANCELLED it at ~13:25 IST every day (all 6 weekdays checked) — a small daily monitoring gap before the queued afternoon session started, plus a daily false ‘cancelled’ status. GitHub’s hard job cap is 360 min, so a start earlier than ~09:50 can’t reach 15:50 in one job; the 340-min self-stop ends it cleanly (success) and lets the already-queued afternoon session (same concurrency group, cancel-in-progress:false) take over. Good Morning Telegram only fires if a tick lands 08:44-08:52 IST (trading_bot.py send_good_morning window) — cron-job.org must therefore START the session ~08:40 IST; at 09:30 the message is never sent | | health_watchdog.py’s EXPECTED_UPI/EXPECTED_WA are PINNED values, not read from the live site | If the owner ever legitimately changes his UPI ID or WhatsApp number, update these constants in the SAME commit — otherwise the payment-integrity check (v1.2) will send a false “possible UPI-swap” alarm every day | | ShadowLedger (trading_bot.py v15.28) is observation-only — NEVER wire it to real/paper trading without the owner explicitly reviewing the scored data first | Same doctrine as ACCUM_AUTOTRADE: it exists to measure whether the 1.5x volume gate is well-calibrated, not to trade on its own. Only promote a finding to a real gate-threshold change after enough scored rows exist and the owner has seen the numbers | | BottomScanner columns + the TrendWatch tab (Prev_Day/Week levels, Support, Retest, Bottom_Stage, Intraday_Setup) are informational-only — NEVER reference them in any gate/Master_Score/Trade_Type without owner approval after seeing backtested numbers | 2026-10-18: built from 74-trade History evidence — entry ABOVE previous-day-high lost (-₹910 net, 35% win) vs below it +₹4,436 (48%); above-prev-WEEK-high looked good for swing (80% win) on only 5 trades — not trusted yet. 5-yr/194-stock backtest (2026-10-18): Pine trendline breakout above EMA50 avg +2.87%/trade (41% win; +3.22% when NIFTY>EMA50) vs random entry +1.68%; ‘confirmed bottom’ +0.96% (worse than random). Edge modest, mostly 2023, survivorship-biased — TrendWatch gathers forward evidence before any promotion. Same doctrine as fetch_ath/ShadowLedger | | Public signal-list pages (/sip-accumulation/, /breakout-signals/) stay TEASER-ONLY and keep their honest-backtest notes — never publish entry price, stop-loss, target or position size there, never remove the ‘no timing edge’ / ‘41% win, median trade a small loss’ wording | 2026-10-18 owner asked for public pages to attract Telegram members: paid value = full trade details in Telegram; trust = showing the real limits. Same doctrine as ShadowLedger/fetch_ath: lists are informational, not wired into any gate | | Weekly lessons: never publish a lesson that fails the quality gate, never repeat a finished lesson, and keep the honest-limit scene — and the daily slide-style education workflow stays paused | 2026-10-18 owner decision (fewer but perfect, evergreen, serious traders): daily slides had near-zero views and kept failing; the lessons use only our own real data, state what they do NOT prove (e.g. the US breakout edge was thin: +1.2% vs +0.9% random) and carry the AI disclosure. Hindi audio was not ear-verified by the author | | Intraday/option alerts must never claim to be instant, and must carry the ‘alert can be 5-10 min old, do NOT chase above X’ line; the morning watch plan stays informational (never a gate), CE-only, and silent (NO-PLAN message) on bearish-regime days | 2026-10-19: free infrastructure (5-min price file + scanner + 5-min bot tick) cannot be fast; History showed same-day cash trades 37% win ~break-even and same-day options 0/5 wins while swing (4d+) 60% win +1.0% — so swing is the headline product and intraday/option ideas are delivered as a PLAN the member triggers on his own live chart. Chasing above the previous-day high also lost in our ledger (-Rs 910 net) | | fetch_ath.py’s All_Time_High/%_from_ATH columns are informational-only — NEVER reference them in Breakout_Stage, Trade_Type, Master_Score, or any appscript.gs GATE without the owner explicitly approving it first | 2026-07-29 owner instruction: “add the ATH check, but don’t fix gate yet.” Same doctrine as ShadowLedger/Sector_Trend (v15.30) — the data exists to make a real gap visible (Breakout_Stage can’t distinguish a genuine new-high breakout from a bounce toward a depressed 52-week ceiling below an older, higher high), not to silently change scoring. Only wire it into a gate after the owner has seen the data and explicitly asks | | fetch_dividends.py’s Div_Yield_%_TTM/Last_Div_Date/Last_Bonus_Split_Date/Last_Bonus_Split_Ratio columns are informational-only — NEVER use them to filter/select CASH candidates or reference them in Master_Score/Trade_Type/Priority_Score/any appscript.gs GATE without the owner explicitly approving it first | 2026-07-30 owner asked which cash stocks “benefit more with dividend, bonus etc.” — full-system audit confirmed no dividend/bonus data existed anywhere in the day-to-day trading engine. Same doctrine as fetch_ath.py/ShadowLedger/Sector_Trend: the data is TRAILING history (NSE’s real forward corporate-actions calendar needs a protected session-cookie API this project deliberately avoids, same fragility class as the 2026-05-27 holiday-list outage), shown for visibility, not silently wired into scoring. Only promote to a gate/filter after the owner has seen the data and explicitly asks |


END OF EVERY SESSION

Before ending, update SESSION.md with:

Also update, every time without exception:

Then run:

git add -A
git commit -m "Session update: [brief description]"
git push

KEY SYSTEM FACTS


See .internal-ops.md for full system documentation. See SESSION.md for current task list and known issues.