Your two-book portfolio system, end to end. US: IndMoney, human execution (internally "Trading OS v3" in n8n). India: Kite Connect algo, in build. Built 11 Aug 2026 · last updated 25 Aug 2026.
One Telegram message before the US open, with an audio version underneath it. Actions are at the top, always. If there's nothing to do it says so explicitly.
Every stop change arrives as a ✅ button. Tap it after you've made the change in IndMoney — that's what writes it into the system's state.
Send ask … for a reasoned answer against your live positions. Send a portfolio screenshot to fix any table drift.
The 5 PM run now also runs the FRM. It executes the deterministic pre-compute first, writes a mandatory FRM SHADOW block into the brief — regime score, tonight's cash target and the reason for it, its proposals, and each candidate declined with the exact criterion it failed — logs those proposals to the scoreboard under author frm-shadow, and arms tomorrow's price conditions for Market Watch to check. The 6 PM digest carries one 🤖 FRM SHADOW summary line off the back of it.
frm_enabled is 0 there are no FRM buttons, no FRM alerts and nothing armed for real — read the proposals the way you would read a second opinion. One thing is asked of you: going live on 31 Aug needs at least one DEPLOY proposal you reviewed and would have tapped, so say so when you see one. See the FRM card.Four layers. Three of them never sleep; one of them is you.
Everything deterministic — reading fills out of Gmail, stop math, alerts, digests, the nightly screen. Free and around the clock.
Everything that needs research and reasoning, on a schedule.
n8n data tables, the Claude project, a private GitHub repo — nothing evaporates.
You place orders and tap done. The broker has no API, so your finger stays on the button by design.
Nothing here is read from your account. This diagram explains the machinery — the counters are sample volumes chosen to show what moves where. Your actual positions, stop losses, targets and watchlist live in the n8n data tables, not in this document. The counters step as the walkthrough runs. Hover or tab to any box to pin its explanation; the walkthrough pauses while you read. Every stage is written out below, in order.
trade_ledger row and adjusts cash inside positions_state. Stop fills get a 🚨 Telegram alert straight away.
positions_state, and works out stop proximity, exact raise-stop instructions and take-profit touches — then sends the Telegram alert and its spoken version.
universe symbols and rewrites screen_results. No Telegram message: this exists purely to give the brain a terminal to read.
positions_state, trade_ledger and the fresh screen into one payload. Without it the brain would be reasoning from a memory of yesterday.
daily_brief and pending_action for tonight, plus a copy in recommendations so the call can be graded later. A second, separately gated call carries the FRM's own proposals into the same scoreboard under author frm-shadow.
positions_state — the system never assumes you acted.
Since v2.4 the whole loop runs on India time: screen, brain and digest all land before the session they describe, not a session late. Post-close, the heartbeat and scorer close the loop before the next screen.
Actions, brain brief, portfolio and recent fills in one message — plus a spoken version.
Hover, tap or tab to any marker to read it here. Every event's full detail is listed below — nothing is hidden behind the chart.
The rail above is the shape of the day. This is the substance.
Market is shut, so it reads the last completed daily bar. Screens the 45-symbol universe, writes screen_results. No Telegram message — deliberately. It exists to feed the brain.
Reads live state plus the fresh screen, researches news, earnings, sector and macro, checks re-entry candidates, evaluates cash deployment. Since v2.7 it executes the deterministic MCP — FRM Precompute first and reasons from those numbers instead of re-deriving them, then writes a mandatory FRM SHADOW block into the brief — regime score, tonight's cash target and the reason for it, the proposals, and every candidate declined with the exact criterion it failed. Writes the full brief to Daily_Briefs.md and pushes structured actions through MCP — Brief Intake, which also logs each one into recommendations for scoring later — FRM proposals among them, under author frm-shadow so they are scored separately. Last step: MCP — FRM Arm arms tomorrow's price conditions and sweeps yesterday's. See the FRM card.
🎯 Actions with ✅ tap-to-confirm buttons, the 🧠 brain brief, portfolio, recent fills — then a spoken version as an mp3. Lands with an hour to spare before the open. Since v2.7 it also carries one 🤖 FRM SHADOW summary line — regime, cash target, what the FRM would have proposed. One line, no buttons: nothing in it is actionable while frm_enabled is 0.
Market Watch starts polling.
Every 30 minutes through the session: warns when a price is within 2% of its stop, tells you exactly when and where to raise one (RAISE STOP — AVGO: 333.50 → 342.40), flags take-profit touches, and sends advisory alerts on manual_override rows. Thresholds come from system_config, not hardcoded. Prices in batches of 6 spaced a minute apart, so the free TwelveData tier is never breached — the batch now covers held and armed symbols. Since v2.7 the same pass checks the conditions the brain armed last night and records the ones that come true; while frm_enabled is 0 it cannot send anything about them.
The daily bar for the session completes.
A session summary sent every session regardless of activity, which says "no actions outstanding" in as many words when there are none. Silence during the session now means checked and quiet, not dead. Text only, on purpose.
Grades every brief action that has come due at 5, 20 or 60 days. Only messages you when something was actually scored.
Gmail checked hourly through US market hours plus a sweep. Any IndMoney order email is parsed into the ledger, cash is adjusted, and you get a Telegram alert — 🚨 for stop fills. Can lag up to about an hour.
Every exit scored saved-vs-whipsaw against holding, cash drag, performance vs SPY and QQQ, compliance stats, improvement proposals. Goes to the project, GitHub reports/, and a digest summary.
The drift auditor. Writes to Notion only — never an action, never a trade. See Decision authority.
Send these to US Stocks — Alerts. Nothing is case-sensitive; the leading slash is optional.
donedone AVGO 342.40 still works if you prefer.statusask <question>cash · cash set 260.67chatidask prefix is required on purpose, so a stray message never burns a model call.system_config row deliberately — no command, no button, no UI. See the FRM card.| Bot | Carries | You send it |
|---|---|---|
| US Stocks — Alerts | Trade alerts, digests, brain briefs, audio notes, command replies | Everything above |
| Monitoring — Trading OS | ⚠️ Failure alerts when any n8n workflow breaks, across every project — not just trading | Nothing. Read-only. |
positions_state. Since v2.6 the tap writes the row's own current guarded level rather than whatever level the button was minted with, and tells you when the two disagree — so a button surfacing from old scrollback can never lower a stop.Anything the model writes now arrives twice — as text first, then as an mp3 you can play with the phone in your pocket. Deterministic one-liners stay text-only, because reading "Stop updated for NVDA ✅" is faster than listening to it.
| Message | Audio | Why |
|---|---|---|
| Daily Digest, 6 PM | yes | The original. A composed spoken briefing, not the document read aloud. |
| The 🤖 FRM SHADOW line inside it | unverified | It rides in the digest text, but the spoken script is composed separately in Build Spoken Text, which the FRM work did not change. Whether the line survives into the mp3 has not been checked against a delivered message — so this row says what is known and no more. |
| Market Watch alerts | yes | Arrives mid-session when you may not be at a screen. Prices and stop levels are dictated slowly. |
ask answers | yes | Reasoning is long-form; listening beats scrolling. |
| Screenshot reconcile report | yes | Model-generated, and worth hearing before you tap Apply. |
| done / status / cash / help / menus | no | One-liners. Audio would be slower than reading. |
| Session heartbeat, 1:35 AM | no | It fires whether or not anything happened. Nightly audio you'd never play is noise. |
| Nightly Screen | n/a | Sends no Telegram message at all — it's a data source for the brain. |
portfolio_meta.cash_usdManually maintained, goes stale silently. cash set <amount> in Telegram is the fastest fix.
recommendationsLeft over from wiring validation. It will otherwise be scored as if real.
True 20-day high is 49.50, so the 15% trail should be 42.08. Currently 40.95.
The universe trend buckets decision is what unblocks it.
Before then, any read on stop widths or entry quality is noise. Too early to conclude anything.
Now governed by the cash-ladder and Layer 2 floor rules in system_config. Watch that they actually close the gap over the coming ladder sessions. Since v2.7 the FRM also states a target cash % and its reason every night in shadow — that is the number to read, while the ladder is still the thing that binds.
The ~6:38 PM SL/TP task in the desktop app. Fully replaced, and it only ever ran when the laptop was awake.
Repair by enabling the instance's public API on PikaPods — N8N_PUBLIC_API_DISABLED=false.
Revisit if annual turnover nears ₹10L, positions become whole-share, or Alpaca's GIFT City entity goes live.
Where trades can actually execute automatically. See the project doc India_Algo_Trading_Kickoff.md.
psLevelThe guarded reader still falls back to an anchored regex for rows written before pending_stop_level existed. Delete it once the column has populated across the book — a regex cannot see negation, so "do NOT raise stop to 46" still yields 46. The guards bound the damage; the column is the real answer.
positions_state.last_updatedThe failure monitor only ever sees a workflow that runs and fails. A trigger that stops firing at all is structurally invisible to it — the same blind spot that produced a false "Market Watch is dead" call on 18 Aug. Watch the timestamp the data carries, not the execution list.
positions_state escapes the guardsExpand Recommendations has no row to check it against, so an unguarded level still lands in recommendations — and Apply Pending Action then silently no-ops on the write. Scoreboard noise rather than a money path, but it fails quietly, which is the pattern worth catching.
Seven shadow sessions end that Monday. The evening review is the only thing that can move frm_enabled from 0 to 1, and it is decided against the four exit criteria, not on impression. Until it happens the FRM proposes into the brief and nothing else. See the FRM card.
Market Hours Gate was hardcoded to the EDT window Resolved 2026-08-21Raised on 19 Aug as a dated deadline — correct through summer, wrong by an hour from 1 Nov 2026. Fixed two days later: the gate resolves the session in America/New_York instead, shipped with Market Watch private and checked over a 37-case boundary matrix plus a 7,200-minute old-vs-new regression with zero mismatches.
Symptom on the left, what it actually means on the right.
Build Digest cross-reads the brief text against the resting stops and surfaces the mismatch. But it was finding the level by matching the brain's prose, and on 18 Aug a differently-phrased action walked straight past it, so the claim that this was "structurally impossible" was too strong. The level now comes from pending_stop_level, not from wording. See the original post-mortem and the one that falsified it.Build Spoken Text / Build Reply Voice / Build Alert Voice. Ticker names and shorthand expansions live there.last_price comes from the last Market Watch poll — 30-minute cadence, and frozen outside US market hours.FRM_PRECOMPUTE_UNAVAILABLE and the brain carries on without it: the primary brief is unaffected, and arming is skipped entirely rather than run on stale numbers. Check MCP — FRM Precompute in the n8n executions tab.macro_calendar silently yields event_clear=true in FRM Compute — indistinguishable from a genuinely quiet week, and it hands the regime score that point either way. A macro_empty flag is the fix and is not built yet.n8n · self-hosted n8n · PikaPods
Polls open positions and tells you exactly when and where to raise a stop.
market_watch.ratchet_hysteresis_pct = 0.25%, not a hardcoded 1%. The old 1% band was silently suppressing genuine raises — the NVDA 179.70 → 180.79 case is what motivated the change.manual_override set (TSM, OUST) still generate an advisory alert when the ratchet math would have raised the stop. Visible, not silent, even though the auto-raise is deliberately skipped.Collect Alerts mints the ✅ Done / ⏰ Later pair on a breach, and since v2.6 the level those buttons carry comes from the shared psLevel reader — pending_stop_level first, an anchored regex only as a transitional fallback, then guards for finite, above the resting hard_stop, below last_price and not below half of it. The node now emits hard_stop with the alert so that guard is live rather than inert. No valid level, no button.saveDataSuccessExecution had been set to none on 16 Aug to suppress a daily no-op. The runs never stopped — only the record of them did, and an empty execution list later read as a two-session outage that had not happened. Restored to all.private) the session is resolved in America/New_York, so the switch to EST costs nothing. Verified over a 37-case boundary matrix and a 7,200-minute old-vs-new regression, zero mismatches.frm_enabled branch, so while the switch is 0 a true condition is recorded and nothing is sent. This took the workflow from 31 to 48 nodes — see the FRM card.market_watch.heartbeat_enabled.Renders positions_state and the fresh brief into one message plus a spoken briefing.
Build Digest cross-reads the fresh brief_text against the resting stops. Any symbol where the brief names a level above the resting hard_stop but pending_action is empty gets surfaced under actions, marked "from brain brief, not tracked in state", with a working button. It caught the August 14 failure; on 18 August it missed a rephrased one, because it was still recovering the level by matching prose. Since v2.6 the tracked and orphan branches both read the level through psLevel. See the post-mortem.pending_stop_level, with an anchored regex kept only as a transitional fallback for rows written before the column existed, and four guards after either path — finite and positive, strictly above the resting hard_stop, below last_price, and not below half of it. Fail-closed: no valid level, no button. The digest never mints one it cannot stand behind.raise stop X → Y, which deleted anything else the sentence carried — a warning that "the Aug 17 stop order was REJECTED", a trim leg reading "AND sell $16.35". The prose is now printed as written. Button rows cap at 5 with an explicit ⚠️ N more… line, so the overflow cliff is loud rather than silent.brief_text and prints in the 🧠 brain-brief section that already existed, which is also why it carries no buttons. None can be minted for it while frm_enabled is 0. See the FRM card.V2's Monthly Trend Scan Step 2 — the systematic universe screen the original strategy called for but which had never actually run. It gives the brain a terminal.
time_series.screen_results wholesale each night.The command handler, locked to one chat id. Handles done (typed and tap-picker), status, ask, cash, chatid, inline-button callbacks, and the screenshot reconcile flow.
positions_state and cash → Apply / Ignore buttons. Proposals are stashed so the callback applies exactly what was shown. Stops are never touched from a photo.ask branch assembles live positions and 14 days of ledger into the prompt, so answers are grounded in real state rather than recollection.n=1..6 send variants. n8n silently drops dynamically-built button arrays; the message sends fine and the buttons just aren't there. Routing by button count is the workaround, and rows are capped at 5 with a ⚠️ N more… line so every reachable count has a variant to route to.Plan Callback ignores the level riding on the wire and re-reads the row's own current guarded level through psLevel, reporting the disagreement when there is one — so a button surfacing from old scrollback applies today's truth or nothing at all. Compute Done Update puts typed prices like done AVGO 342.40 through the same guards, and Build Done Menu builds its picker from them, so /done and the buttons can no longer disagree about what is pending.Parses fills, cancellations and dividends into trade_ledger, deduped by gmail_message_id, adjusts cash, and alerts Telegram. Stop-loss fills get a distinct 🚨 treatment.
Closes the loop the brain opens: every action it recommends gets measured, not just acted on and forgotten.
recommendations (private) for rows due at 5, 20 or 60 calendar days.frm-shadow rows, and the FRM's proposals are scored in a section of their own — so a shadow run can neither flatter nor drag the metric the 31 Aug go-live review reads. Published private (rollback private); output byte-identical while zero shadow rows exist.What feeds it: MCP — Brief Intake logs every action the brain writes into recommendations at the moment the brief is delivered. The scoreboard fills automatically, with no separate step. Since v2.7 a second, separately gated call adds the FRM's own proposals under author frm-shadow — same table, counted apart.
Claude scheduled tasks can't authenticate n8n's data tables directly in headless runs. So: one read endpoint, one write endpoint, each token-guarded, each with both an MCP path and a plain webhook fallback.
| Bridge | Direction | What it carries |
|---|---|---|
MCP — State Exportprivate | Read | positions_state, 30 days of trade_ledger, the latest daily_brief — one JSON object. |
MCP — Brief Intakeprivate | Write | The brief text plus the structured channels below. |
MCP — Repo Commitprivate | Write | Commits a file to zoeb-nomi/trading-os on main. Used for monthly reports. |
Added v1.1, after the brain could describe an action in prose but had no way to write it into state.
| Field | Effect |
|---|---|
actions[] | Writes pending_action and pending_since. Deliberately ignores manual_override, so judgment calls on hand-managed rows still reach the action list instead of being swallowed. Accepts a numeric level or free-text action. Since v2.6 Parse Intake keeps the level it already computed and writes it to pending_stop_level, with a pending_kind beside it, instead of rendering it into a sentence and leaving every downstream reader to recover it. |
clear_actions[] | Clears a pending action once actioned or superseded — including pending_stop_level and pending_kind, so a button that survives in scrollback cannot re-apply something already done. |
flags[] | Sets manual_override per symbol — the mechanism for taking a row out of automatic stop-raising. |
earnings_updates[] | Writes confirmed next_earnings dates onto positions. |
Expand Recommendations puts each level through the same guards before logging it to recommendations. A level the guards reject is still logged, labelled stop_raise_rejected, so a rejection shows up on the scoreboard rather than quietly disappearing.{
"token": "…",
"date": "2026-08-14",
"brief_text": "…",
"actions": [{ "symbol": "OUST", "level": 40.95 },
{ "symbol": "TSM", "action": "decide TP: let 460 fill, raise to ~500, or partial exit" }],
"clear_actions": ["NVDA"],
"flags": [{ "symbol": "OUST", "manual_override": false }],
"earnings_updates": [{ "symbol": "GOOG", "next_earnings": "2026-10-27" }]
}
Query-string fallbacks exist too: ?actions=OUST:40.95&flags=OUST:false. The new nodes branch in parallel off Respond OK rather than chaining, so an empty array can't break the chain.
The brain now calls Brief Intake twice. The first call is the brief as it always was. The second carries only the FRM's proposals, logged to recommendations under author frm-shadow, and is made only once the first has returned ok:true.
daily_brief or positions_state at all. The safety latch: a shadow call omits brief_text, so the pre-v2.7 intake would have rejected it with zero writes.price_at_rec comes from screen_results, not from the payload — a caller cannot post the price its own call will later be scored against. Inserts are deduped.private (rollback private), legacy path deep-equal regressed. First shadow session, 21 Aug: 3 rows written — XBI, XOP, ERX — author frm-shadow, 0 rejected, 0 duplicates.config echo doubles as the canonical read of system_config, which is what finally closed the long-standing absence of a config-read tool.The judgment layer that owns cash allocation inside hard rails. It proposes; it never executes. Three tiers: rails that are never crossed, bands it may breach only in writing, and its own judgment in between. The kill switch is one config row — frm_enabled — and it is 0.
private) is read-only and deterministic — regime score, gate-v2 math per candidate, the band-vs-actual and rail-headroom table, and a config echo. MCP — FRM Arm (private) writes armed_conditions, runs the nightly expiry sweep, and returns yesterday's would-have-fired outcomes. Both are manual/MCP-only: no webhook, no token, nothing published. Code crunches, the brain judges.frm_enabled=1, so a condition that comes true today lands as a shadow_fired row and nothing more.armed_conditions (private), 14 columns, one row per armed trap. MCP-write only — no webhook may ever write it. That is a standing invariant, the same one system_config carries, and it is why arming got its own workflow instead of another channel on Brief Intake, which has a webhook surface.recommendations under author frm-shadow, and arms conditions for the next session. The 18:00 digest carries one 🤖 FRM SHADOW summary line.Reference docs, in the Claude project: US_Financial_Risk_Manager_Spec.md · US_FRM_ArmAndWatch_Spec_F3b.md · US_FRM_GoLive_Review_Pack.md · Shared_n8n_Credential_Map.md.
The parent layer above FRM-US (this system) and FRM-IN (the India build). Federated, not centralised: one written risk standard, two independent instances, separate kill switches, no runtime call between legs on any money path. New in v3.0, ratified 25 Aug 2026.
frm_enabled here, in_frm_enabled in India — and neither calls the other at runtime on any money path. A human with a phone and a bot with an API key never share a failure domain.in_frm_enabled=0 themselves; only a human re-arms it. global_no_new_risk is the one flag shared across both legs — it blocks new entries only, never exits or stops.Reference docs, in the Claude project: Shared_Portfolio_Allocator_Spec.md v1.1 — §9 holds the paste-ready PRM monthly-pass prompt, pending Zoeb's paste into the monthly audit prompt.
Every workflow points its errorWorkflow setting here. Any failure, in any project, sends an alert to the monitoring bot. Production executions only, not manual test runs. This cost twenty minutes to build and has caught everything since.
Three separate places now speak — the digest, Claude's replies, and market alerts — but all three use the same shape, so there is exactly one pattern to understand and one place per surface to tune.
ER→earnings, R/R→reward to risk, 50d→fifty day, +2.4%→up 2.4 percent, $180.79→180.79 dollars); caps length; and returns nothing at all if the result is too short to be worth an audio file.POST https://api.openai.com/v1/audio/speech — model gpt-4o-mini-tts, voice cedar, mp3, with a full delivery brief in instructions. Retries once, then continues on error.data, titled per surface. Continues on error.| Surface | Script node | Voice character |
|---|---|---|
| Daily Digest | Build Spoken Text | Markets-desk anchor giving a private pre-open briefing. 170–185 wpm, weight on stop levels, urgency on overdue items. |
| Claude replies | Build Reply Voice | Analyst answering one question out loud. Conversational, beats between points. |
| Market alerts | Build Alert Voice | Risk officer calling mid-session. Brief, 165–180 wpm, numbers dictated. Adds "tap done once it's moved in IndMoney" only on raise-stop alerts. |
The built-in node cannot pass instructions — the parameter that makes gpt-4o-mini-tts steerable. Its schema exposes only model, input, voice, response_format and speed. Without instructions the read is flat regardless of voice. Audio moved off that node permanently on 14 Aug.
onyx, nova and fable are legacy tts-1 voices no longer in the current list. Available now: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar. If cedar stops landing, try marin (warmer), ash (crisper) or sage.
| Table | Holds |
|---|---|
positions_state | Units, stops, trail %, 20-day highs, earnings dates, manual_override, and since v2.6 the structured pair pending_stop_level (number, nullable) and pending_kind (stop_raise, stop_raise_and_trim, tp_decision, hold, other, stop_raise_rejected). pending_action is still there and still carries the brain's own sentence, but it is now descriptive prose only, never parsed for a level. The system's working state. |
trade_ledger | Every fill since 1 July, deduped by Gmail message id. |
daily_brief | The brain's free-text brief, one row per date. |
portfolio_meta | Cash. Manually maintained — see open item 1. |
universeprivate | 45 rows. The symbol master list the Nightly Screen reads. |
screen_resultsprivate | 25 columns, one row per symbol, fully replaced each night. |
recommendationsprivate | Every brief action, scored at 5 / 20 / 60 days. |
system_configprivate | 39 rows of flags and thresholds. See below. |
macro_calendar | FOMC, CPI and NFP dates with a red/amber severity, seeded through Dec 2026. Added with the FRM: it is what the regime score's event test and the binary-event guard read. |
armed_conditionsprivate | 14 columns. The price traps the brain sets each night for Market Watch to spring — symbol, condition, level, size, expiry, status. MCP-write only; no webhook may ever write it, the same standing invariant system_config carries. |
universeEvery row has a role, and the role is what decides whether a symbol can ever be proposed as a holding.
| Role | Count | Meaning |
|---|---|---|
benchmark | 2 — SPY, QQQ | The denominator for relative-strength maths. Structurally never proposable. |
sector | 11 | The SPDR sector ETFs — measurement instruments implementing V2 Step 2. Also never holdable. |
candidate | 27 | Across 7 trends. The actual scan universe for new opportunities. |
held | 5 | Current positions, present so real MAs, ATR and a true 20-day high are computed from live data rather than assumed. |
system_config — every threshold in one place39 rows — 23 of them arrived with the FRM delta, the Tier 1 rails among them. Every V3 decision rule reads its thresholds and switches from here rather than a hardcoded constant, so any rule can be tuned, reverted, or turned fully off via its enabled flag without editing a workflow.
| Module | Keys |
|---|---|
| market_watch | ratchet_hysteresis_pct = 0.25 · heartbeat_enabled · override_advisory_enabled |
| deployment | cash_ladder_enabled · cash_target_max_pct = 10 · cash_ladder_l1_sessions = 3 · cash_ladder_l2_sessions = 5 · cash_ladder_l2_deploy_frac = 0.33 |
| allocation | layer2_floor_pct = 10 · anticorrelation_required · position_cap_pct = 20 · factor_cap_pct = 45 tier 2 |
| entry | reentry_split_enabled · reentry_consolidation_days = 3 · leveraged_allowed = 1 since 20 Aug |
| screen | screen_enabled |
| measurement | scoreboard_enabled |
| frm | frm_enabled = 0 · frm_shadow = 1 · frm_rr_min = 1.5 · frm_stop_atr_mult = 3 · frm_stop_floor_pct = 8 · frm_stop_cap_pct = 15 · frm_stop_cap_leveraged_pct = 25 · frm_target_atr_mult = 6 · frm_high_proximity_pct = 5 · frm_session_deploy_cap_pct = 20 · frm_min_order_usd = 10 · frm_leveraged_regime_min = 3 · frm_universe_review_sessions = 5 |
| rails | rail_name_max_pct = 35 · rail_factor_max_pct = 65 · rail_cash_max_pct = 40 · rail_leveraged_max_pct = 15 · rail_leveraged_single_max_pct = 10 tier 1 |
| regime | regime_vix_max = 25 · regime_breadth_min_pct = 50 |
frm_enabled is one row and the only way to take the FRM live. No Telegram command, no button, no UI — deliberately, so it cannot be flipped by anything except an edit made on purpose. 0 today. See the FRM card.rail_* rows are the hard edge instead. The old cash-target and ladder enforcement only retires when frm_enabled flips to 1 — until then both rule sets are in the file and the ladder is the one that binds. The three tiers are set out in the rules section.factor:ai_semis = NVDA, AVGO, TSM, MU, SOXL, NVDL and factor:ai_platforms = GOOG, after the 20 Aug ruling that GOOG is demand-side. The book read 44.08% and 21.45% against the 45% band on 21 Aug. The 21.7-point definitional gap that made the factor number unusable — with a third of the book riding on it — is gone.The 5:00 PM IST daily brain. Everything else that looks like review is deliberately read-only.
screen_results, and which strong trend currently has no exposure at all. Writes only to Notion. Never to positions_state, never an action, never a trade.frm_enabled row in system_config — one row, no command, no UI, 0 today. See the FRM card and the three tiers.Since v2.7 these are not all the same kind of rule. Tier 1 rails are validated in code and never crossed. Tier 2 bands are defaults the FRM steers toward and may breach only in writing. Tier 3 is its judgment in between — and that is the part being scored.
frm_enabled flips to 1; its honesty obligation does not — every FRM night states a target cash % and the reason for it, whether or not anything is proposed.entry.leveraged_allowed was flipped to 1 on 20 Aug. The rejected backtest still stands as the reason they are not held casually, so the rails are tight: ≤15% of the book in total, ≤10% in any single one, proposable only at regime score ≥3, and a 25% stop cap rather than 15%, because a 15% stop on a 3× instrument is one day's range.These are the exact prompts behind the two scheduled Claude tasks. They are the system's actual behaviour, so treat them as configuration: change them via a Claude chat — "update the daily brain trigger prompt to …" — with one exception, below. A prompt edit only affects the next run; if today's output looks unfixed, check whether the run predated the edit.
You are Zoeb's Trading OS v3 daily pre-open brain, running weekdays at 5:00 PM IST (before the 7 PM IST US market open, after the 4:45 PM IST nightly screen). Zoeb runs a concentrated high-conviction US equity portfolio on IndMoney (manual execution, fractional shares) under his "Portfolio System V2: Opportunity Engine": three layers (Core compounders, 20-25% trailing stops; Trend Amplifiers, 12-15%; Tactical, 8-10%), tiered stop ratchet = trail_pct below 20-day high, stops NEVER lowered. Principles: thesis-driven exits not price-action panic; never add into binary events; never hold tight stops through earnings; cash deploys only through opportunity identification against V2 entry criteria (confirmed trend above 50/200d MA, volume support, >2:1 reward/risk, exit defined BEFORE entry), never mechanically. A deterministic n8n layer already handles fills (Gmail-ingested), config-driven stop-ratchet alerts with an end-of-session heartbeat, the 6:00 PM IST Telegram digest, the 4:45 PM IST nightly screen of the watch universe, and the 2:15 AM IST recommendation scorer — you are the sole decision-maker on this system; the weekly and monthly reviews are read-only; Zoeb executes all trades manually. STEPS: 1. GET LIVE STATE via the n8n bridge (execute workflow "MCP — State Export"; webhook fallback with token). Sanity-check last_price freshness; note staleness but don't block. 2. RESEARCH per holding: news, catalysts, analyst moves, CONFIRM next earnings date; sector health; today's macro events. For stopped-out names: re-entry check (reclaimed exit price / 20d MA?). 3. JUDGE: per position hold/action + reasoning; stop assessment vs events (earnings within 5 days = tight-stop rule); cash >10% → specific deployment candidate with entry/stop/target per V2 criteria, or explicit "hold cash because X"; top 1-3 risks. 4. DELIVER: (a) condensed brief <1400 chars, strict format — 🎯 ACTIONS (one line per action, exact change + reply syntax) / 📌 WHY (one bullet per stock, ~15 words) / ⚠️ RISKS — sent via "MCP — Brief Intake" with confirmed earnings_updates, actions[], clear_actions[] and flags[]; (b) full reasoned brief with sources prepended to project doc claude/Daily_Briefs.md. Be honest about uncertainty. Never claim a trade was executed — you only advise.
recommendations as frm-shadow, and 4a3 arming conditions through MCP — FRM Arm. Note that the >2:1 reward/risk in the body above is V2's consensus-PT criterion — the one gate v2 replaces for FRM proposals, which are gated at ≥1.5:1 against a momentum target instead. See the FRM card.On the 1st of each month at 7:00 AM IST, produce the monthly performance and counterfactual report for the PREVIOUS month. 1. DATA from the n8n bridge (State Export: positions, ledger last 30 days). 2. PRICES via web for every ticker traded in the month + SPY/QQQ month start/end. 3. ANALYSIS: (a) per-exit counterfactual — proceeds vs value-if-held-to-today, classify "SL saved money" vs "whipsaw cost", with dollars; (b) cash drag vs QQQ during idle periods; (c) portfolio return vs SPY/QQQ; (d) compliance — recommendations vs confirmed placements; (e) 2-4 data-grounded improvement proposals. Mark estimates vs exact fills; one month is a small sample; never conflate compliance failures with strategy failures. 4. DELIVER: full report → project doc claude/Monthly_Report_YYYY-MM.md; same report committed to GitHub via "MCP — Repo Commit" (reports/YYYY-MM.md); condensed summary → "MCP — Brief Intake" so it appears in the morning digest.
What you'll see in the list.
| Task | Schedule | Status |
|---|---|---|
| Trading OS v3 — Nightly Screen | Daily 4:45 PM | active the decision-maker's data source |
| Trading OS — Daily Pre-Open Brain prompt v3 | Weekdays 5:00 PM | keep the only decision-maker |
| Trading OS v3 — Recommendation Scorer | Daily 2:15 AM | keep the scoreboard |
| Trading OS — Weekly Review | Mondays 9:00 AM | keep read-only drift auditor, Notion only |
| Trading OS — Monthly Counterfactual Report | 1st, 7:00 AM | keep historian, not a decision-maker |
| Monthly rebalance (created & deleted 14 Aug, same day) | — | do not recreate |
| Old daily SL/TP task (desktop app, ~6:38 PM) | Daily | disable fully replaced; it only ran when the laptop was awake |
| mirror-eval surface monitor | Weekly Mon | leave as is unrelated to trading |
Every user-facing time in this guide is IST and verified. Under the hood some schedules are stored in UTC, because Claude's scheduler requires UTC crons — but US market hours are not fixed in UTC, which is what the daylight-time fix below is about.
America/New_York and the twice-yearly switch costs nothing.recommendations scoreboard as author frm-shadow, separable from the human calls.armed_conditions, Market Watch checks them every 30 minutes intraday — record-only until frm_enabled=1. See the FRM card.macro_calendar seeded through Dec 2026.positions_state gained pending_stop_level and pending_kind. The stop level is a column now, not something a reader has to recover from a sentence./raise stop to ([0-9.]+)/i retired from all six sites it had been copied to, in favour of one shared psLevel reader: structured column first, an anchored regex as a transitional fallback, then four guards — finite and positive, strictly above the resting hard_stop, below last_price, not below half of it. Fail-closed: no valid level, no button.⚠️ N more… line, so every reachable count has a send variant. Market Watch's success logging restored to all.ask answers, the screenshot reconcile report, and all six Market Watch alert branches.$-to-dollars rewrite swallowed trailing punctuation, so "$185." spoke as "185. dollars".recommendations table.recommendations at the moment it is written, so the scoreboard fills with no separate step.system_config table — 17 flags and thresholds.manual_override rows, so a skipped auto-raise is visible rather than silent.universe table — 45 symbols across 7 trends.screen_results table — 25 metrics, replaced nightly.actions[] / clear_actions[] / flags[] write channels.manual_override turned off./v1/audio/speech call, voice onyx → cedar, spoken script rewritten.The 6:30 AM digest showed Actions needed → none today while the brain brief immediately below it said "OUST: raise stop 37.50 to 40.95". Missed for two consecutive days.
Fault 1 — the brief was never wired to the action list. The two sections come from different places: actions from positions_state.pending_action, the brief from daily_brief.brief_text, a free-text blob. Build Digest computed pendings = open.filter(p => p.pending_action). Nothing ever read brief_text, and Brief Intake had no channel to write pending_action. So any judgment call the brain made lived in prose only, invisible to the action list.
Fault 2 — manual_override = true on OUST blocked the only writer that existed. Market Watch guards the ratchet with if (!p.manual_override && p.trail_pct && stop). The flag was set on both TSM and OUST at build time. For TSM that is correct — its 364 stop and 460 TP are hand-chosen. For OUST it was wrong: a plain 15% trailing-stop rule with nothing hand-picked about it. Net effect: OUST was double-blocked. The watcher refused to write the action; the brain had no way to.
actions[], clear_actions[] and flags[]; OUST came off override; and Build Digest gained the brief-vs-state reconciliation pass. TSM keeps manual_override = true by design.Under the old schedule the brief was written at 4:30 PM IST — well timed, 2.5 hours before the 7 PM open — but only delivered inside the next morning's 6:30 AM digest, roughly 14 hours later, by which point the session it described had already closed. The analysis was timely; the delivery was a full session late.
No overnight Market Watch alerts arrived for a stretch. The workflow was running every 30 minutes and succeeding — Collect Alerts simply returned empty every cycle, because nothing crossed a threshold.
manual_override.The spoken-script builders rewrote $185. as "185. dollars" and $180.79, as "180.79, dollars", because the pattern /\$\s?([0-9][0-9,.]*)/ greedily consumed the trailing full stop or comma into the number.
/\$\s?(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/ and fixed in all three script builders at once.The Nightly Screen's n8n description and its canvas sticky note both stated it ran at 02:00 IST and "posts a Telegram summary". The trigger is 16:45 IST and there is no Telegram node in the workflow at all — there never was.
The 18 August digest arrived with five pending actions and not one ✅ Done button. Execution 4358, status success, the workflow untouched since the 17th — same versionId throughout. Nothing broke. The brain simply phrased that day's actions differently than the day before.
What the code was looking for. Six code nodes across four workflows carried the same literal /raise stop to ([0-9.]+)/i. What the brain actually wrote that day was raise stop 181.60 to 182.34 (20% below true 20d high 227.92) and place/raise hard stop to 346.18 — the Aug 17 stop order was REJECTED…. None of the five matched, the keyboard rows came out empty, and the count router fell through to the one send node that has no keyboard by design. On that branch buttons were structurally impossible. On the 17th a single action happened to be phrased raise stop to 42.62 — which is why exactly one button rendered that day, and why the failure looked partial rather than total.
Every route to confirming was dead, not just the buttons. /done used the same regex, skipped every row, and replied "Nothing pending 🎉" against five pending actions. The typed fallback the digest itself printed led to "no recommended level is pending". The system was not merely unhelpful that evening — it was confidently wrong in three places at once, and every one of them reported success.
The one-line fix would have been worse than the bug. Widening the regex was blocked at the pre-upgrade gate, tested against the day's real strings. The widened capture spans " — the Aug " and mints done:AVGO:17.00 on a $392 stock; on NVDA it greedily takes 227.92, above the last traded price, so one tap is an instant stop-out. The callback handler trusted whatever level rode the wire absolutely. The obvious change would have converted a cosmetic bug into a money-path one.
positions_state.pending_stop_level, and all six sites now read it through one shared guarded reader — column first, anchored regex only as a transitional fallback, then guards for finite and positive, strictly above the resting hard_stop, below last_price, and not below half of it. No valid level, no button. Taps re-read the row rather than trusting what the button carries. Verified offline — 33/33 on the guard suite, 12/12 on an independent adversarial pass against the server-stored code — because the digest messages you for real and so can never be used as a test. Confirmed live on retrigger: execution 4393, three keyboard rows returned.Two rule candidates were backtested on 14 Aug 2026 and both rejected. Recorded here so they aren't re-proposed without new evidence.
Tested across all four leveraged pairs in the universe — SOXL/SMH, DFEN/ITA, ERX/XLE, LABU/XBI. The gate was anti-selective in every pair: the days it excluded outperformed the days it included, and this held at every threshold tested from the 40th to the 75th percentile. There is no percentile cut of this gate that helps. SOXL, DFEN, ERX and LABU therefore stay in universe as measurable, for relative-strength context, but are forbidden as holdings via entry.leveraged_allowed = 0.
leveraged_allowed is now 1. What this backtest killed was the timing gate — the claim that a percentile filter could tell you which days to hold a 3× instrument — and that finding stands. It was never evidence that a leveraged position is unholdable at any size. Under the FRM the four are proposable inside hard rails instead: ≤15% of the book in total, ≤10% in any single one, regime score ≥3, and a 25% stop cap rather than 15%. See the FRM card.Rejected for a measurement reason rather than a performance one: no stop rule can be validated yet, because every historical stop-out predates the 11 August build and was set by hand. The backtest would have been measuring a rule that never actually governed those exits. Revisit once enough stop-outs have occurred under the live tiered-trail rule.
Every one of these failed silently. That is the pattern worth remembering.
An executeOnce setting truncated the position list to a single item, so Market Watch "succeeded" sixteen times a day while doing nothing. Fix: nodes read upstream data by explicit reference — $('Node').all() — never by implicit input item.
n8n's Telegram node silently discards dynamically-built inline keyboards. The message sends fine; the buttons simply aren't there. Fix: the keyboard shape is literal and only the labels and values are expressions — which is why there are n=1..6 send variants routed by button count.
TwelveData returns {"price":"1.23"} for one symbol but {"AAPL":{"price":"1.23"}} for many. Code written against the many-shape breaks the day a batch has one item. Fix: responses are normalised at the boundary, before any business logic touches them.
The Nightly Screen's Normalize Chunk Prices node resolved chunk metadata via itemMatching(0), pinning every chunk's metadata to the first chunk — so only the first 6 of 45 symbols ever produced real data. Silently. Fix: the node is now metadata-free and derives each symbol from its own API response. Verified 45/45 symbols, 260 bars each.
Adding a Get Config node upstream of Get Open Positions caused an unintended per-item fan-out — 5 positions crossed with config rows multiplied to 85 items — producing one Telegram message with the same alert repeated seventeen times. Fix: executeOnce on the positions node, so config is read once per run rather than once per position.