SKILL.md
---
name: fyers-supercharge
description: >-
Supercharge Mode — an iterative, multi-agent optimization loop for FYERS trading
strategies. Offered automatically after a strategy's first successful backtest. A central
orchestrator convenes a council of specialist strategists (risk, return, entry, exit,
regime, execution, options, capital efficiency, robustness, backtest integrity) that study
the strategy, debate improvements, generate optimized variants, backtest them, and search
for measurably better versions across rounds — pausing for the user every three rounds. No
single agent decides; the output is council consensus, and the original strategy is always
preserved as the baseline. Use when the user wants to optimize, improve, harden, evolve, or
stress-test a strategy, or accepts the "Unlock Supercharge Mode?" offer. Triggers:
"supercharge", "supercharge mode", "optimize my strategy", "improve my strategy",
"make my strategy better", "strategy variants", "strategy council", "evolve my strategy",
"stress test my strategy".
version: 0.1.0
license: MIT
allowed-tools:
- Agent
- Bash
- Read
- Write
- Edit
- Glob
- Grep
- WebFetch
---
# Supercharge Mode — Multi-Agent Optimization Loop
Turn a single backtested strategy into an evolving research project. A central **orchestrator**
convenes a **council of specialist strategists** that study the strategy, debate improvements,
generate optimized variants, backtest them, and keep searching for measurably better versions —
in iterative rounds, keeping the user involved every three rounds.
The objective is **not to validate** a strategy — it is to **improve** it. Instead of "Is this
strategy good?", the question is "How can this strategy become significantly better?" — while
protecting against overfitting and needless complexity. Every change is justified, tested, and
compared against previous versions. **No single agent decides — the output is council consensus.**
> **The original strategy is always preserved as the immutable baseline.** Every variant is
> measured against it. Never modify the baseline.
> **Depends on `fyers-trading`.** This skill optimizes a strategy that `fyers-trading` built and
> backtested; it reuses that skill's backtest path (`references/backtesting.md`) and metrics
> (`scripts/quantstats_report.py`) so variants are scored on the same yardstick as the baseline.
> Install both skills together. **All of `fyers-trading`'s real-money safety rules apply unchanged**
> (secrets via env, dry-run by default, symbol validation, live-order confirmation, rate limits):
> the council may *recommend* deploying or going live, but never places a live order itself.
## When this activates
Supercharge Mode is offered **automatically after a strategy completes its first successful
backtest** (the `fyers-trading` skill hands off here). Present the offer:
> **Your strategy is ready.**
>
> **Unlock Supercharge Mode?**
>
> Our AI team of professional trading strategists will analyze your strategy, debate improvements,
> generate multiple optimized variants, backtest them, and recommend stronger alternatives. The
> optimization runs in iterative cycles and keeps you involved every three rounds before continuing.
If the user says **Yes**, launch immediately. Also activate on explicit intent to optimize /
improve / evolve / stress-test a strategy. If the user only wants to *build or run* a strategy,
that's `fyers-trading` — come back here once a backtest exists.
## How it works — hybrid orchestration
**You (the host agent) are the orchestrator.** You run the reasoning and debate by spawning one
subagent per council seat via the `Agent` tool. The **deterministic** work — scoring, ranking, and
persistence — is delegated to three bundled scripts so every variant is measured identically and
the journey is reproducible:
- `scripts/scorecard.py` — one standardized metric dict per variant (reuses `fyers-trading`'s
`quantstats_report.key_metrics()`; computes trade-level metrics itself).
- `scripts/dashboard.py` — ranks the baseline + all variants for the active objective.
- `scripts/evolution_log.py` — append-only JSONL journal of every round, variant, and decision.
## The optimization loop
Each round runs six phases — read **`references/optimization-loop.md`** for the full lifecycle and
the consensus rule:
1. **Understand** — every seat studies the rules, sizing, capital, timeframe, and backtest evidence.
2. **Diagnose** — each seat names strengths, weaknesses, hidden risks, and metrics to improve.
3. **Debate** — seats challenge each other; the orchestrator resolves conflicts into experiments.
4. **Generate variants** — candidates that each change only a few variables (`references/variants.md`).
5. **Backtest** — every variant, identical assumptions, via `fyers-trading`; scored by `scorecard.py`.
6. **Learn** — what worked / failed feeds the next round; results recorded via `evolution_log.py`.
Produce variants each aimed at a specific objective: higher returns, lower drawdown, better
risk-adjusted performance, higher win rate, better capital efficiency, better execution, or better
robustness across regimes. The goal is a **library** of deployment-ready variants, not one "best".
## The council
One subagent per seat, spawned via `Agent`, briefed from **`references/council.md`** (12 seats:
Risk, Return, Entry Precision, Exit Intelligence, Market Regime, Backtest Integrity, Execution,
Options, Capital Efficiency, Simplicity & Robustness, Variant Generation, Performance Ranking).
The roster is **extensible without changing the loop** — add a seat by adding an entry.
## Checkpoint every three rounds
**Never continue silently.** After every three rounds, pause and present the optimization report
(improvements, variants created/discarded, metrics improved/worsened, major disagreements, current
leader, recommended next direction), then wait for the user to choose a direction — continue, focus
on drawdown / returns / consistency / frequency / execution, or finalize. See
**`references/checkpoints.md`**.
## Final deliverables
When the user finalizes: an Executive Summary, the Strategy Evolution Timeline, the Variant
Library, the Comparison Dashboard, and the AI Consensus. See **`references/deliverables.md`**.
## References
| Topic | Read |
|---|---|
| The 6-phase loop + consensus rule | `references/optimization-loop.md` |
| Council roster (12 seats, mandates, vetoes) | `references/council.md` |
| Variant contract, strategy interface, Variant Library | `references/variants.md` |
| Metrics + Comparison Dashboard (where each number comes from) | `references/metrics.md` |
| User checkpoints every 3 rounds | `references/checkpoints.md` |
| Final deliverables + success criteria | `references/deliverables.md` |
| Overfitting / robustness guardrails | `references/overfitting.md` |
## Scripts
- `scripts/scorecard.py` — standardized per-variant metric dict; reuses `fyers-trading`'s
`quantstats_report.key_metrics()` with a stdlib fallback; computes trade-level metrics and a
robustness score. CLI: `demo`.
- `scripts/dashboard.py` — ranks the baseline + variants for an objective and renders the
Comparison Dashboard as Markdown. CLI: `demo`.
- `scripts/evolution_log.py` — append-only JSONL journal (mirrors `fyers-trading`'s
`trade_logger.py`): `log_round`/`log_variant`/`log_decision`/`log_checkpoint` +
`timeline`/`checkpoint_report`/`summary`. CLI: `demo` / `timeline` / `summary` / `tail`.
> Full-fidelity scoring needs `fyers-trading` **and** QuantStats installed. Without them the
> scripts still run (stdlib fallback), but CAGR/Calmar and the HTML tear sheet are unavailable.
references/metrics.md
# Metrics & the Comparison Dashboard
Every variant — and the immutable baseline — is scored on the **same** metric set so comparisons
are apples-to-apples. `scorecard.py` produces one uniform dict per candidate; `dashboard.py` ranks
them for the active objective. "Improvement" always means a quantified delta versus the baseline
(and versus the current best variant), never a qualitative opinion.
## Where each number comes from
Two sources, deliberately kept separate:
- **Risk metrics — from `fyers-trading`'s `quantstats_report.key_metrics()`** (the same code that
scored the baseline): `sharpe`, `sortino`, `max_drawdown`, `cagr`, `calmar`, `volatility_ann`,
`positive_period_pct`. Computed off the per-bar returns series (`strat_ret`).
- **Trade-level metrics — computed by `scorecard.py` from the trades list** (QuantStats never sees
"trades"): `net_profit`, `return_pct`, `profit_factor`, `win_rate`, `avg_profit`, `avg_loss`,
`trade_count`, `largest_loss`, `longest_losing_streak`, `return_on_capital`.
- **Caller-supplied estimates** (not derivable from returns alone; the relevant seat provides
them, else `None`): `capital_utilization` (Capital Efficiency seat), `execution_risk`
(Execution seat).
- **Derived by `scorecard.py`:** `robustness_score` (in/out-of-sample Sharpe stability — see
`overfitting.md`).
> If `fyers-trading` / QuantStats isn't installed, `scorecard.py` falls back to a stdlib
> computation of Sharpe/Sortino/max-drawdown/vol so the loop still runs, but full-fidelity scoring
> (CAGR, Calmar, the tear sheet) needs `fyers-trading` present. Install both skills together.
## ⚠️ Two win rates — never conflate
- **`win_rate`** — TRADE-LEVEL: winning trades / total trades. This is the intuitive "win rate".
- **`positive_period_pct`** — QuantStats' % of positive PERIODS (e.g. positive days).
They answer different questions and will differ. The dashboard reports both under distinct
columns; never relabel `positive_period_pct` as "win rate" (mirrors
`fyers-trading/references/quantstats.md`).
## The Comparison Dashboard metric set (PRD)
| Metric | Scorecard key | Source |
|---|---|---|
| Net Profit | `net_profit` | trades |
| Return % | `return_pct` | trades + capital |
| Profit Factor | `profit_factor` | trades (gross profit / gross loss) |
| Sharpe Ratio | `sharpe` | quantstats |
| Sortino Ratio | `sortino` | quantstats |
| Win Rate (trade-level) | `win_rate` | trades |
| Average Profit | `avg_profit` | trades (mean winning trade) |
| Average Loss | `avg_loss` | trades (mean losing trade) |
| Maximum Drawdown | `max_drawdown` | quantstats |
| Trade Count | `trade_count` | trades |
| Return on Capital | `return_on_capital` | trades + capital |
| Capital Utilization | `capital_utilization` | caller (Capital Efficiency seat) |
| Largest Loss | `largest_loss` | trades |
| Longest Losing Streak | `longest_losing_streak` | trades |
| Execution Risk | `execution_risk` | caller (Execution seat) |
| Robustness Score | `robustness_score` | derived (in/out-of-sample) |
Also carried for context: `cagr`, `calmar`, `volatility_ann`, `positive_period_pct`.
## Objectives → ranking metric
`dashboard.py` ranks on one metric per objective (the checkpoint "focus" menu maps onto these):
| Objective | Ranks by | Direction |
|---|---|---|
| `risk_adjusted` (default) | `sharpe` | higher |
| `higher_returns` | `return_pct` | higher |
| `low_drawdown` | `max_drawdown` | higher (closer to 0) |
| `high_win_rate` | `win_rate` | higher |
| `consistency` | `sortino` | higher |
| `capital_eff` | `return_on_capital` | higher |
| `execution` | `execution_risk` | lower |
| `robustness` | `robustness_score` | higher |
## Reporting a proposal's improvement
State it as a delta: *metric → baseline value → variant value → basis (the backtest run)*. A
proposal that can't be expressed this way doesn't enter the debate (`optimization-loop.md`).
Guard against improving one metric while quietly wrecking another — the dashboard shows the whole
row so trade-offs are visible, which is a success criterion of the mode.
references/variants.md
# Variants — contract, discipline, and the Variant Library
A variant is a candidate strategy derived from the baseline to pursue **one** objective. Pro
Supercharge Mode produces *many* variants, not a single "best" — the deliverable is a library of
deployment-ready alternatives (below).
## The variant contract (declared before backtesting)
Every variant declares, up front:
- **Objective** — the one thing it optimizes (higher returns, lower drawdown, better
risk-adjusted, higher win rate, better capital efficiency, better execution, better robustness).
- **Rules changed** — the specific rules/parameters altered from the baseline.
- **Rules preserved** — what is deliberately held constant (so the change is attributable).
- **Expected benefit** — the metric expected to improve, and by roughly how much.
- **Expected downside** — the metric expected to worsen (there is almost always a trade-off).
- **Success metrics** — the scorecard fields that decide whether it worked.
**Change only a limited number of variables per variant.** One conceptual change at a time (e.g.
"tighten the stop" OR "add a trend filter", not both) so Phase 6 can attribute the result. A
variant that changes ten things at once teaches nothing even if it wins.
## The strategy interface every variant must expose
`fyers-trading` documents a per-bar returns series (`df["strat_ret"]`) as the hand-off into the
metrics layer, but it defines **no formal runner interface** — so Supercharge Mode imposes a
thin one so `scorecard.py` can score every variant identically:
A variant is a self-contained runner that, over the shared backtest window, produces:
1. **`strat_ret`** — a per-bar returns Series (pandas), exactly as in
`fyers-trading/references/backtesting.md` §2 (`signal.shift(1) * ret`, costs subtracted). This
feeds the risk metrics (Sharpe/Sortino/drawdown/CAGR/…).
2. **`trades`** — a list of closed-trade dicts, each with at least `{"pnl": <float in currency>}`
(plus optional `entry`/`exit`/`side`/`qty`). This feeds the **trade-level** metrics QuantStats
structurally cannot compute (win rate, profit factor, avg win/loss, largest loss, streaks).
Score it with:
```python
from scorecard import scorecard
# freq="auto" resamples intraday bar returns down to daily before scoring (matching the baseline);
# pass freq="D" only if strat_ret is already daily. Use the SAME freq for every variant + baseline.
card = scorecard(variant_df["strat_ret"], trades=variant_trades, capital=CAPITAL, rf=0.07)
```
`scorecard()` normalizes the returns with `fyers-trading`'s `daily_returns()` **before**
computing risk metrics — exactly as `fyers-trading/references/backtesting.md` does — so an
intraday `strat_ret` series is annualized correctly rather than producing a bogus Sharpe/drawdown.
Both artifacts must come from the **same** data window, cost model, and capital as the baseline
(Phase 5's "identical assumptions"). Do not compare a variant scored on 2023 to a baseline scored
on 2022–2024.
## Where variants live
Under the strategy folder, isolated from the baseline so the baseline stays intact:
```
strategies/<name>/
... # the original strategy (baseline) — never modified
supercharge/
evolution.jsonl # the evolution log (evolution_log.py)
variants/
v1_tighter_stop/ # one folder per variant: runner + config + scorecard.json
v2_trend_filter/
...
```
## The Variant Library (final deliverable taxonomy)
When optimization concludes, the accepted variants are organized into this library. A slot is
filled only if a variant genuinely earns it — do not fabricate a variant to fill a slot.
- **Best Overall** — best risk-adjusted performance across the board.
- **Conservative** — lowest risk / smoothest equity, modest returns.
- **Aggressive** — highest returns, accepts higher drawdown.
- **High Win Rate** — highest trade-level win rate (may have smaller average wins).
- **Low Drawdown** — smallest max drawdown.
- **Capital Efficient** — best return on deployed capital / margin.
- **Execution Optimized** — lowest execution risk / most realistic to trade live.
- **Regime Specific** — tuned for a particular regime (e.g. trending markets); note the regime.
- **Experimental** — promising but not yet robust; flagged as higher-risk / needs more validation.
Each library entry includes its objective, rule changes, backtest results (its scorecard), and a
**recommended use case**. All are compared on the same metrics via the Comparison Dashboard
(`metrics.md`, `dashboard.py`).
references/council.md
# The Strategist Council — roster
The orchestrator spawns one subagent per seat (via the `Agent` tool). Each strategist analyzes
the strategy **only** from its own domain, proposes measurable improvements (each tied to a metric
in `metrics.md` with an expected delta), and critiques other seats' proposals during the debate
(`optimization-loop.md`). **No seat decides alone** — proposals become experiments only through
the consensus rule.
For each seat: **Domain** (what it studies), **Mandate** (what it proposes), **Owned metrics**
(what it is judged on), **Veto** (what, if anything, it can block).
> **Extensible by design (PRD).** The roster below is the starting council, "including but not
> limited to" these seats. New strategist seats can be added over time **without changing the
> orchestration flow** — the 6-phase loop, the debate, and the scorecard/dashboard contracts are
> seat-agnostic. To add a seat, append an entry here with the same four fields; the orchestrator
> spawns whatever seats this file lists.
## Risk Strategist
- **Domain:** drawdown, tail risk, position sizing, leverage, exposure/concentration.
- **Mandate:** sizing and stop/exposure rules that cap ruin risk without killing edge.
- **Owned metrics:** max_drawdown, volatility_ann, largest_loss, longest_losing_streak.
- **Veto:** ruin-risk — can block a proposal that materially raises probability of blow-up.
## Return Strategist
- **Domain:** profitability — net profit, return %, capital growth.
- **Mandate:** grow returns; argue against changes that quietly sacrifice profit for optics.
- **Owned metrics:** net_profit, return_pct, cagr, profit_factor.
- **Veto:** none (advisory).
## Entry Precision Strategist
- **Domain:** entry logic — signal timing, confirmation, false-signal filtering.
- **Mandate:** sharpen entries (better fills, fewer whipsaws) without adding fragile complexity.
- **Owned metrics:** win_rate (trade-level), avg_profit, trade_count quality.
- **Veto:** none (advisory).
## Exit Intelligence Strategist
- **Domain:** exit logic — stops, targets, trailing rules, time-based exits.
- **Mandate:** improve how trades are closed (let winners run, cut losers) — the other half of edge.
- **Owned metrics:** avg_profit, avg_loss, profit_factor, largest_loss.
- **Veto:** none (advisory).
## Market Regime Strategist
- **Domain:** behavior across regimes (trend/range, high/low vol, event days, sessions).
- **Mandate:** expose regime dependence; propose regime filters / robustness improvements.
- **Owned metrics:** per-regime return/Sharpe, consistency, robustness_score.
- **Veto:** none (advisory) — but its regime evidence often triggers the Integrity veto.
## Backtest Integrity Strategist
- **Domain:** validity of the evidence — look-ahead, survivorship, overfitting, data-snooping.
- **Mandate:** ensure every claimed improvement is real, not a backtest artifact. Owns the
guardrails in `overfitting.md`.
- **Owned metrics:** robustness_score, in-vs-out-of-sample gap, parameter sensitivity.
- **Veto:** invalid evidence — can block a proposal whose backtest support is unsound or overfit.
## Execution Strategist
- **Domain:** real-world tradability — slippage, costs, fills, order types, rate-limit cadence.
- **Mandate:** keep the strategy executable under FYERS limits (10/s · 200/min · 100k/day) and
honest about costs. Bridges into `fyers-trading`'s orders/rate-limits references.
- **Owned metrics:** execution_risk, net-of-cost return, turnover, orders/min feasibility.
- **Veto:** infeasibility — can block a proposal that can't be executed within broker/rate limits.
## Options Strategist
- **Domain:** options-specific structure — strike/expiry selection, greeks, IV, spreads, decay.
- **Mandate:** for options strategies, improve structure (defined risk, theta/vega posture). For
non-options strategies this seat is dormant (contributes nothing rather than forcing options in).
- **Owned metrics:** strategy-dependent (margin efficiency, payoff asymmetry, IV sensitivity).
- **Veto:** none (advisory).
## Capital Efficiency Strategist
- **Domain:** how hard the capital works — utilization, margin, idle cash, return on capital.
- **Mandate:** improve return per unit of deployed capital/margin, not just absolute return.
- **Owned metrics:** return_on_capital, capital_utilization.
- **Veto:** none (advisory).
## Simplicity & Robustness Strategist
- **Domain:** parsimony — number of parameters/rules, complexity vs. benefit.
- **Mandate:** resist unnecessary complexity; favor changes that survive out-of-sample. Guards
against the classic "one more parameter" overfit.
- **Owned metrics:** parameter count, robustness_score, complexity penalty.
- **Veto:** complexity — can block a proposal whose added complexity isn't justified by a robust,
out-of-sample improvement.
## Variant Generation Strategist
- **Domain:** turning debated consensus into concrete candidate strategies.
- **Mandate:** author the variants (per the contract in `variants.md`), changing only a limited
number of variables per variant so each improvement stays measurable.
- **Owned metrics:** none directly (produces the variants others score).
- **Veto:** none.
## Performance Ranking Strategist
- **Domain:** comparing candidates fairly on the standardized scorecard.
- **Mandate:** run `scorecard.py`/`dashboard.py`, rank variants for the active objective, and
surface the leader + trade-offs. Keeps the comparison apples-to-apples.
- **Owned metrics:** owns the dashboard, not a single metric.
- **Veto:** none.
---
## Consensus & vetoes (summary — full rule in `optimization-loop.md`)
A proposal becomes an experiment when it draws **no unresolved veto** and a **majority of
contributing seats agree**. Veto-holding seats (Risk, Backtest Integrity, Execution, Simplicity &
Robustness) can block within their mandate; a veto must be resolved (proposal amended) or the
proposal is dropped. Unresolved conflicts are **not** silently decided — they are recorded as
dissents (`evolution_log.log_decision`) and surfaced at the next checkpoint. *(This is the default
rule; it can be swapped for a different aggregation without changing the loop.)*
scripts/dashboard.py
#!/usr/bin/env python3
"""Comparison dashboard for Supercharge Mode (stdlib only).
Ranks the immutable baseline and every optimized variant on the SAME metric set (produced by
`scorecard.py`) and renders the Comparison Dashboard table the PRD calls for. Ranking is
objective-aware: "lower drawdown" sorts by max_drawdown, "higher returns" by return_pct, etc.
The dashboard never decides on its own — it orders candidates so the council (and the user at a
checkpoint) can see which variant leads for the current objective. The baseline is always shown
for comparison and is never dropped.
Functions
---------
rank(cards, objective="risk_adjusted") -> list[dict]
cards: {name: scorecard_dict}. Returns a list of rows (dicts) sorted best-first for the
objective, each row = the scorecard plus "name" and "rank".
render_markdown(cards, objective="risk_adjusted", columns=None, title=None) -> str
A Markdown table (baseline + variants), ranked, with the leader marked. The heading
always carries the FYERS mark (see fyers_title) so the skill is highlighted.
fyers_title(subject) -> str
Ensure a report title/subject contains "FYERS" (prefixes it when missing). Reuse this for
every report the skill emits — dashboards, tear sheets, and the final deliverables.
OBJECTIVES -> dict mapping objective key -> (metric, direction) used for ranking.
Usage
-----
from dashboard import rank, render_markdown
print(render_markdown({"baseline": base_card, "v1_tighter_stop": v1_card},
objective="low_drawdown"))
CLI
---
python dashboard.py demo # synthetic baseline + variants -> ranked table, no token/network
"""
from __future__ import annotations
import sys
# objective key -> (scorecard metric, "max"|"min"). These mirror the checkpoint "focus" menu in
# references/checkpoints.md and the Variant Library objectives in references/variants.md.
OBJECTIVES: dict = {
"risk_adjusted": ("sharpe", "max"), # default: best Sharpe
"higher_returns": ("return_pct", "max"),
"low_drawdown": ("max_drawdown", "max"), # drawdown is negative; closer to 0 = larger
"high_win_rate": ("win_rate", "max"),
"consistency": ("sortino", "max"),
"capital_eff": ("return_on_capital", "max"),
"execution": ("execution_risk", "min"), # lower execution risk is better
"robustness": ("robustness_score", "max"),
}
# Default columns shown in the rendered table (subset of the full scorecard, in reading order).
DEFAULT_COLUMNS = [
"net_profit", "return_pct", "profit_factor", "sharpe", "sortino", "win_rate",
"max_drawdown", "trade_count", "return_on_capital", "largest_loss",
"longest_losing_streak", "robustness_score",
]
def fyers_title(subject: str) -> str:
"""Return a report title that always carries the FYERS mark so the skill is highlighted.
If `subject` already mentions FYERS (any case), it's returned unchanged; otherwise it's
prefixed with "FYERS ". Every report title/subject the skill emits should pass through here."""
subject = (subject or "").strip()
if not subject:
return "FYERS Report"
if "fyers" in subject.lower():
return subject
return f"FYERS {subject}"
def _sort_key(metric: str, direction: str):
"""Build a sort key that pushes missing (None) values to the bottom regardless of direction."""
worst = float("-inf") if direction == "max" else float("inf")
def key(row: dict):
v = row.get(metric)
if v is None:
return worst
try:
v = float(v)
except (TypeError, ValueError):
return worst
return v
return key
def rank(cards: dict, objective: str = "risk_adjusted") -> list:
"""Rank scorecards best-first for `objective`. `cards` maps name -> scorecard dict.
Unknown objective falls back to the default (risk_adjusted). Rows carry "name" and 1-based
"rank". Ties keep input order (Python sort is stable)."""
metric, direction = OBJECTIVES.get(objective, OBJECTIVES["risk_adjusted"])
rows = [dict(card, name=name) for name, card in cards.items()]
rows.sort(key=_sort_key(metric, direction), reverse=(direction == "max"))
for i, row in enumerate(rows, start=1):
row["rank"] = i
return rows
def _fmt(v) -> str:
"""Format a cell: percents/ratios to a sensible precision, None -> '—'."""
if v is None:
return "—"
if isinstance(v, bool):
return str(v)
if isinstance(v, float):
return f"{v:,.4f}".rstrip("0").rstrip(".") if abs(v) < 1000 else f"{v:,.0f}"
return str(v)
def render_markdown(cards: dict, objective: str = "risk_adjusted",
columns: "list | None" = None, title: "str | None" = None) -> str:
"""Render the ranked comparison as a Markdown table string.
The leader (rank 1) is marked with a ★ next to its name. The active objective and the metric
it sorts on are noted above the table for transparency.
`title` is the report heading. It defaults to a **FYERS**-branded title so the skill is
highlighted wherever the report is shown; any caller-supplied title is likewise prefixed with
"FYERS " when it doesn't already mention FYERS (report titles always carry the FYERS mark)."""
if not cards:
return "_(no scorecards to compare)_"
cols = columns or DEFAULT_COLUMNS
metric, direction = OBJECTIVES.get(objective, OBJECTIVES["risk_adjusted"])
rows = rank(cards, objective)
heading = fyers_title(title or "Supercharge — Comparison Dashboard")
header = "| # | Strategy | " + " | ".join(cols) + " |"
sep = "|---|---|" + "|".join(["---"] * len(cols)) + "|"
lines = [
f"# {heading}",
"",
f"**Objective:** `{objective}` "
f"(ranked by `{metric}`, {'higher' if direction == 'max' else 'lower'} is better)",
"",
header,
sep,
]
for row in rows:
name = row["name"] + (" ★" if row["rank"] == 1 else "")
cells = " | ".join(_fmt(row.get(c)) for c in cols)
lines.append(f"| {row['rank']} | {name} | {cells} |")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI demo (no token / no network)
# ---------------------------------------------------------------------------
def _demo() -> int:
"""Build synthetic scorecards (via scorecard.py) for a baseline + 3 variants and render."""
import random
# Import the sibling scorecard module regardless of CWD.
import os
here = os.path.dirname(os.path.abspath(__file__))
if here not in sys.path:
sys.path.insert(0, here)
from scorecard import scorecard # type: ignore
def synth(seed, drift, vol, n_trades, avg_pnl):
rng = random.Random(seed)
rets = [rng.gauss(drift, vol) for _ in range(252)]
trades = [{"pnl": rng.gauss(avg_pnl, 400)} for _ in range(n_trades)]
return scorecard(rets, trades=trades, capital=100_000, rf=0.07)
cards = {
"baseline": synth(1, 0.0004, 0.012, 50, 90),
"v1_tighter_stop": synth(2, 0.0005, 0.009, 62, 70), # lower vol, more trades
"v2_trend_filter": synth(3, 0.0007, 0.013, 38, 160), # higher drift, fewer trades
"v3_vol_target": synth(4, 0.0006, 0.008, 55, 110), # smoother
}
print(render_markdown(cards, objective="risk_adjusted"))
print()
print(render_markdown(cards, objective="low_drawdown"))
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "demo":
raise SystemExit(_demo())
print(__doc__)
references/checkpoints.md
# User checkpoints — every three rounds
Supercharge Mode **pauses after every three optimization rounds**. It never continues silently:
the user stays in control of direction. The orchestrator assembles the report from the evolution
log (`evolution_log.checkpoint_report(round_no)`) and presents it, then waits for the user's choice
before starting the next three rounds.
## The optimization report (what the pause shows)
Built from the last three rounds via `evolution_log.checkpoint_report()`, plus the current
dashboard (`dashboard.py`):
- **Improvements achieved** — metrics that moved vs. baseline and vs. the previous leader.
- **Variants created** — every candidate produced this window (`variants_created`).
- **Variants discarded** — and *why* (`variants_discarded`, with the recorded `reason`).
- **Metrics that improved** — with deltas.
- **Metrics that worsened** — trade-offs made, stated honestly.
- **Major disagreements** — the notable council conflicts on record (`major_disagreements`).
- **Current leading strategy** — the present best variant (`current_leader`) + its scorecard.
- **Recommended next optimization direction** — the council's suggestion, with rationale.
## The user's choices
At the checkpoint the user picks one (recorded via `evolution_log.log_checkpoint(..., user_choice)`):
| Choice | Effect on the next rounds |
|---|---|
| Continue optimization | Keep the current objective. |
| Focus on reducing drawdown | Objective → `low_drawdown`. |
| Focus on increasing returns | Objective → `higher_returns`. |
| Focus on improving consistency | Objective → `consistency` (Sortino / steadier equity). |
| Focus on increasing trade frequency | Objective → more trades (Entry Precision seat leads). |
| Focus on execution quality | Objective → `execution` (Execution seat leads). |
| Finalize current best variants | Stop the loop → assemble final deliverables (`deliverables.md`). |
The chosen objective sets the dashboard ranking metric (`metrics.md`) and which seats lead the
next debate. The loop then runs three more rounds and pauses again — until the user finalizes.
## Orchestrator responsibilities at a checkpoint
1. Generate the report data (`checkpoint_report`) and render the current dashboard.
2. Present it plainly in chat — improvements, trade-offs, disagreements, leader, recommendation.
3. **Wait for an explicit choice.** Do not auto-continue.
4. Record the choice (`log_checkpoint`) and set the next objective before resuming.
references/deliverables.md
# Final deliverables
When the user finalizes (at a checkpoint — `checkpoints.md`), Supercharge Mode stops the loop
and assembles five deliverables. All are built from the evolution log
(`evolution_log.timeline()`) and the per-variant scorecards — a single source of truth, so the
whole journey is reproducible and explainable.
> **Always brand report titles with "FYERS".** Every report's subject/title/top heading must
> contain **FYERS** so the skill is highlighted wherever the report is shown — the Executive
> Summary, the Comparison Dashboard, any QuantStats HTML tear sheet, and each variant write-up.
> The scripts do this for you: `dashboard.render_markdown()` emits a FYERS-branded heading, and
> `dashboard.fyers_title(subject)` prefixes any title with "FYERS " when it's missing (reuse it
> for hand-written report headings). When calling `fyers-trading`'s `quantstats_report.html_report(...)`,
> pass a `title=` that includes FYERS (e.g. `"FYERS Supercharge — <strategy> Best Overall"`).
## 1. Executive Summary
A concise overview of the optimization journey and the final recommendation: where the baseline
started, what was tried, what won, and which variant(s) to deploy for which purpose. A few
paragraphs — the thing a busy user reads first.
## 2. Strategy Evolution Timeline
Every round, in order (straight from `evolution_log.timeline()`):
- Changes introduced that round.
- Performance impact (metric deltas).
- Accepted improvements.
- Rejected ideas — and *why* they were rejected (the recorded `reason`).
This is the auditable history — it satisfies the "maintain a complete evolution history" success
criterion.
## 3. Variant Library
The collection of accepted, deployment-ready variants, organized by the taxonomy in
`variants.md` (Best Overall, Conservative, Aggressive, High Win Rate, Low Drawdown, Capital
Efficient, Execution Optimized, Regime Specific, Experimental). Each entry: objective, rule
changes, backtest results (its scorecard), and recommended use case. Slots are filled only when a
variant genuinely earns them.
## 4. Comparison Dashboard
Every variant + the baseline compared on the consistent metric set, rendered by
`dashboard.py` (`render_markdown`) — see `metrics.md` for the full column set. Show it ranked for
the finalized objective, with the leader marked.
## 5. AI Consensus
The orchestrator's closing summary of the council's reasoning:
- **Why the winning strategy was selected** — the decisive metrics and which seats backed it.
- **Which recommendations were rejected** — and the reasoning.
- **Remaining weaknesses** — what's still not solved (no strategy is perfect).
- **Confidence in the final recommendation** — calibrated by robustness (`overfitting.md`), not
just in-sample performance.
- **Suggested next experiments** — where a future run should look.
## Success criteria (what "done well" means)
Supercharge Mode succeeded when it:
- Produced variants **measurably better** than the original on their target metric.
- Improved targeted metrics **without disproportionate damage** elsewhere (the dashboard makes
trade-offs visible).
- **Guarded against overfitting** via out-of-sample / robustness checks (`overfitting.md`).
- Made every decision **transparent and explainable** (evolution log + AI Consensus).
- Maintained a **complete evolution history**.
- Kept the user involved through **checkpoints every three rounds**.
- Delivered **multiple deployment-ready variants**, not a single "best".
references/optimization-loop.md
# The optimization loop
Supercharge Mode runs in **iterative cycles**. Each cycle (a "round") takes the current
strategy state, runs the council through six phases, and produces backtested variants plus a
recorded decision. The system pauses for the user every **three rounds** (`checkpoints.md`).
The orchestrator (the host agent) drives this; the deterministic parts — scoring, ranking,
persistence — are delegated to `scorecard.py`, `dashboard.py`, and `evolution_log.py`.
> **The baseline is sacred.** The original strategy is captured once, scored, and never mutated.
> Every variant is measured against it (and against the current best variant). See `variants.md`.
## Round lifecycle — the six phases
### Phase 1 — Understand
Every seat studies the strategy and its evidence: entry logic, exit logic, position sizing,
capital usage, instrument selection, timeframe, and the backtest output (per-trade list, equity
curve, returns series, risk metrics, market conditions). Capture the baseline scorecard first
(`scorecard.py`) so everything downstream is measured against it.
### Phase 2 — Diagnose
Each seat, from its domain, identifies: strengths, weaknesses, bottlenecks, hidden risks, missed
opportunities, unstated assumptions, and the specific metrics that most need improvement for the
active objective.
### Phase 3 — Debate
Seats openly challenge one another — the disagreement is the point. Typical clashes:
- Risk Strategist recommends tighter stops → **Return Strategist** argues profits will decline.
- **Execution Strategist** flags that tighter stops increase slippage/whipsaw at the tick level.
- **Simplicity & Robustness Strategist** warns an added filter is unjustified complexity.
The orchestrator resolves disagreements via the **consensus rule** (below) and converts the
surviving proposals into concrete experiments. Every debate outcome — including the major
disagreements and whether they were resolved — is recorded with `evolution_log.log_decision()`.
### Phase 4 — Generate variants
The Variant Generation Strategist turns consensus experiments into candidate strategies per the
contract in `variants.md`. **Each variant changes only a limited number of variables** so the
resulting improvement is attributable and measurable. Each variant declares: objective, rules
changed, rules preserved, expected benefit, expected downside, success metrics.
### Phase 5 — Backtest
Every variant is backtested under **identical assumptions** to the baseline — same data window,
same costs/slippage model, same capital — via `fyers-trading`'s documented backtest path
(`references/backtesting.md`). Each variant is then scored with `scorecard.py` (which reuses
`fyers-trading`'s `quantstats_report.key_metrics()`), and `dashboard.py` compares it against:
the original strategy, the previous best variant, and all active variants.
### Phase 6 — Learn
The council reviews results: which changes worked, which failed, any unexpected behavior, and new
hypotheses. Accepted/rejected variants and the round's leader are recorded
(`evolution_log.log_variant()` / `log_round()`). The strongest ideas seed the next round.
## Consensus rule (default)
This converts debated proposals into experiments. **No single agent decides.**
1. **Independent proposals** — each seat proposes changes, each tied to a metric in `metrics.md`
with an expected delta. Proposals with no measurable target don't enter the debate.
2. **Cross-critique** — every seat reviews every other seat's proposals: agree, object (with
reason), or veto (only where its mandate allows — see `council.md`).
3. **Revision** — a proposer may revise once to address critique. A veto must be resolved (amend
to clear it) or the proposal is dropped.
4. **Aggregation** — a proposal becomes an experiment when it has **no unresolved veto** and a
**majority of contributing seats agree**. Ties / unresolved conflicts are **not** silently
decided — they are logged as dissents and surfaced at the next checkpoint for the user.
> This majority-plus-veto rule is the default. It can be replaced (e.g. weighted vote, or
> orchestrator-synthesizes-with-justification) **without changing any phase** — only this section.
## What each round persists (via `evolution_log.py`)
- `log_round(round_no, objective, leader, notes)` — the round header + current leader.
- `log_decision(round_no, summary, disagreements, resolved)` — the debate outcome.
- `log_variant(round_no, variant_id, scorecard, decision, rules_changed, ..., reason)` — one per
variant, with the standardized scorecard and WHY it was accepted/rejected/parked.
This append-only journal is the single source of truth the final deliverables are built from
(`deliverables.md`) and what the checkpoint report rolls up (`checkpoints.md`).
references/overfitting.md
# Overfitting & robustness guardrails
The optimization loop searches hard for improvements — which is exactly how overfitting creeps
in. These guardrails, owned by the **Backtest Integrity** and **Simplicity & Robustness** seats,
keep "better" honest. They back those seats' vetoes and the `robustness_score` in the scorecard.
## The core discipline
**An improvement that only exists in-sample is not an improvement.** Every accepted variant must
show its edge survives data it wasn't tuned on. A curve fit to one period is not a strategy.
## Guardrails
1. **Out-of-sample / walk-forward split.** Reserve a tail of the data the council never tunes on.
`scorecard.robustness_score()` splits the returns chronologically (default: last 30%
out-of-sample) and compares in-sample vs. out-of-sample Sharpe — score near 1 = edge holds,
near 0 = edge vanishes out-of-sample. Prefer walk-forward (roll the split) for strategies with
enough data.
2. **Parameter-sensitivity check.** A robust rule degrades *gracefully* as its parameters move. If
a variant only works at exactly `stop = 1.2%` and collapses at 1.0% or 1.4%, it's fit to noise.
Test a small neighborhood around any tuned parameter.
3. **Complexity penalty.** Each added rule/parameter must earn its place with a robust,
out-of-sample gain. The Simplicity & Robustness seat can veto complexity that isn't justified —
prefer the simpler variant when two perform comparably.
4. **Deflated-Sharpe caution / trial counting.** The more variants you test, the more likely one
looks great by luck. Track how many variants were tried (the evolution log does this) and treat
a lone standout among many trials with suspicion — favor improvements that repeat across rounds.
5. **Identical assumptions.** Same data window, cost model, slippage, and capital for every
candidate (Phase 5). A "win" from a quietly-easier assumption is not a win.
6. **Enough trades.** Metrics from a handful of trades aren't reliable. Weight confidence by
`trade_count`; a high win rate over 6 trades means little.
## How this shows up in the flow
- The **Robustness Score** column in the dashboard (`metrics.md`) surfaces guardrail #1 on every
candidate.
- The Backtest Integrity seat can **veto** a proposal whose evidence is unsound (look-ahead,
survivorship, overfit) — see `optimization-loop.md`'s consensus rule.
- The **AI Consensus** deliverable calibrates its confidence by robustness, not raw in-sample
performance (`deliverables.md`).
Also carry over `fyers-trading/references/backtesting.md`'s pitfalls (look-ahead via `.shift(1)`,
survivorship, partial last candle, timezone, corporate actions) — those are backtest-correctness
bugs that masquerade as edge.
scripts/evolution_log.py
#!/usr/bin/env python3
"""Append-only evolution log for Supercharge Mode (stdlib only).
Records the strategy's optimization journey so nothing is lost between rounds and the final
deliverables (Strategy Evolution Timeline, Executive Summary, AI Consensus) can be assembled from
a single source of truth. Mirrors fyers-trading's `trade_logger.py`: one JSON object per line,
never truncated or rotated, logging must never crash the caller.
Unlike the trade log (which is global at ~/.fyers/trades.jsonl), the evolution log is PER
STRATEGY and lives inside the strategy folder so it travels with the work:
strategies/<name>/supercharge/evolution.jsonl
Build that path with ``default_path("<name>")``; pass the resulting string as the `path` arg
(or --path on the CLI) so each strategy keeps its own log. Omitting the name falls back to a
``_unassigned`` placeholder that stays inside the same layout rather than sharing one global log.
Record types
------------
round — a completed optimization loop: {round, objective, leader, notes}
variant — a generated + backtested variant: {round, variant_id, objective, scorecard,
rules_changed, rules_preserved, decision ("accepted"|"rejected"|"parked"), reason}
decision — a debate/consensus outcome: {round, summary, disagreements, resolved}
checkpoint— a user checkpoint every 3 rounds: {round, report, user_choice}
Functions
---------
log_round(round_no, objective, leader=None, notes=None, path=DEFAULT_PATH)
log_variant(round_no, variant_id, scorecard, decision, objective=None,
rules_changed=None, rules_preserved=None, reason=None, path=DEFAULT_PATH)
log_decision(round_no, summary, disagreements=None, resolved=None, path=DEFAULT_PATH)
log_checkpoint(round_no, report, user_choice=None, path=DEFAULT_PATH)
timeline(path=DEFAULT_PATH) -> list[dict] every record, in order
checkpoint_report(round_no, path=DEFAULT_PATH) -> dict rolls up the last 3 rounds
summary(path=DEFAULT_PATH) -> dict aggregate counts + current leader
CLI
---
python evolution_log.py demo # write a synthetic journey to a temp log + read back
python evolution_log.py timeline [--path P] # print every record
python evolution_log.py summary [--path P]
python evolution_log.py tail --n 20 [--path P]
"""
from __future__ import annotations
import datetime
import json
import os
import sys
def default_path(strategy: str = "_unassigned") -> str:
"""Build the per-strategy evolution-log path documented in references/variants.md:
``strategies/<strategy>/supercharge/evolution.jsonl``. Pass the strategy name so each
strategy gets its own log — callers that omit it share the ``_unassigned`` fallback."""
return os.path.join("strategies", strategy, "supercharge", "evolution.jsonl")
# Fallback default when no strategy name is supplied. Kept INSIDE the documented per-strategy
# layout (strategies/<name>/supercharge/…) via a placeholder name, so it never writes outside it
# and doesn't silently collide with a named strategy's log. Prefer default_path("<name>").
DEFAULT_PATH = default_path()
# ---------------------------------------------------------------------------
# Internal helpers (mirror trade_logger.py)
# ---------------------------------------------------------------------------
def _now_utc() -> str:
return datetime.datetime.utcnow().isoformat() + "Z"
def _append(record: dict, path: str) -> None:
"""Append *record* as one JSONL line. Never raises (logging must not crash the loop)."""
try:
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, default=str) + "\n")
except Exception: # noqa: BLE001
pass
def _read(path: str) -> list:
"""Return all parsed records from the log, or [] if it doesn't exist / can't be read."""
if not os.path.isfile(path):
return []
out: list = []
try:
with open(path, encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
out.append(json.loads(raw))
except json.JSONDecodeError:
continue
except Exception: # noqa: BLE001
return out
return out
# ---------------------------------------------------------------------------
# Writers
# ---------------------------------------------------------------------------
def log_round(round_no: int, objective: str, leader: "str | None" = None,
notes: "str | None" = None, path: str = DEFAULT_PATH) -> None:
"""Record a completed optimization loop."""
_append({
"record_type": "round",
"timestamp": _now_utc(),
"round": round_no,
"objective": objective,
"leader": leader,
"notes": notes,
}, path)
def log_variant(round_no: int, variant_id: str, scorecard: dict, decision: str,
objective: "str | None" = None, rules_changed: "list | None" = None,
rules_preserved: "list | None" = None, reason: "str | None" = None,
path: str = DEFAULT_PATH) -> None:
"""Record a generated + backtested variant and the council's decision on it.
`decision` is one of "accepted" | "rejected" | "parked". `scorecard` is the dict from
scorecard.py. `reason` captures WHY (kept for the transparency success-criterion)."""
_append({
"record_type": "variant",
"timestamp": _now_utc(),
"round": round_no,
"variant_id": variant_id,
"objective": objective,
"scorecard": scorecard,
"rules_changed": rules_changed or [],
"rules_preserved": rules_preserved or [],
"decision": decision,
"reason": reason,
}, path)
def log_decision(round_no: int, summary: str, disagreements: "list | None" = None,
resolved: "bool | None" = None, path: str = DEFAULT_PATH) -> None:
"""Record a debate/consensus outcome, including the major disagreements on record."""
_append({
"record_type": "decision",
"timestamp": _now_utc(),
"round": round_no,
"summary": summary,
"disagreements": disagreements or [],
"resolved": resolved,
}, path)
def log_checkpoint(round_no: int, report: dict, user_choice: "str | None" = None,
path: str = DEFAULT_PATH) -> None:
"""Record a user checkpoint (fired every 3 rounds) and the direction the user chose."""
_append({
"record_type": "checkpoint",
"timestamp": _now_utc(),
"round": round_no,
"report": report,
"user_choice": user_choice,
}, path)
# ---------------------------------------------------------------------------
# Readers
# ---------------------------------------------------------------------------
def timeline(path: str = DEFAULT_PATH) -> list:
"""Return every record in the log, in write order (the raw evolution timeline)."""
return _read(path)
def summary(path: str = DEFAULT_PATH) -> dict:
"""Aggregate counts + the most recent leader across the whole log."""
recs = _read(path)
stats = {
"total_records": len(recs),
"rounds": 0,
"variants": 0,
"variants_accepted": 0,
"variants_rejected": 0,
"variants_parked": 0,
"checkpoints": 0,
"current_leader": None,
}
for r in recs:
rt = r.get("record_type")
if rt == "round":
stats["rounds"] += 1
if r.get("leader"):
stats["current_leader"] = r["leader"]
elif rt == "variant":
stats["variants"] += 1
d = r.get("decision")
if d == "accepted":
stats["variants_accepted"] += 1
elif d == "rejected":
stats["variants_rejected"] += 1
elif d == "parked":
stats["variants_parked"] += 1
elif rt == "checkpoint":
stats["checkpoints"] += 1
return stats
def checkpoint_report(round_no: int, path: str = DEFAULT_PATH) -> dict:
"""Roll up the last 3 rounds ending at `round_no` into the checkpoint report contents.
Assembles exactly what references/checkpoints.md prescribes: variants created/accepted/
rejected in the window, metrics that moved, the major disagreements, and the current leader.
The recommended next direction is left to the orchestrator/council — this provides the data.
"""
recs = _read(path)
window = set(range(max(1, round_no - 2), round_no + 1)) # 3-round window
created, accepted, rejected, disagreements = [], [], [], []
leader = None
for r in recs:
rnd = r.get("round")
if rnd not in window:
# Track the leader from earlier rounds only — NEVER from rounds after `round_no`,
# so recomputing an old checkpoint from a longer log shows the leader as of that
# checkpoint, not one from a later optimization round.
if (r.get("record_type") == "round" and r.get("leader")
and isinstance(rnd, int) and rnd <= round_no):
leader = r["leader"]
continue
rt = r.get("record_type")
if rt == "variant":
created.append(r.get("variant_id"))
if r.get("decision") == "accepted":
accepted.append(r.get("variant_id"))
elif r.get("decision") == "rejected":
rejected.append(r.get("variant_id"))
elif rt == "decision":
disagreements.extend(r.get("disagreements", []))
elif rt == "round" and r.get("leader"):
leader = r["leader"]
return {
"round": round_no,
"window_rounds": sorted(window),
"variants_created": created,
"variants_accepted": accepted,
"variants_discarded": rejected,
"major_disagreements": disagreements,
"current_leader": leader,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _arg_path(args: list) -> str:
if "--path" in args:
i = args.index("--path")
try:
return args[i + 1]
except IndexError:
print("--path requires a value", file=sys.stderr)
raise SystemExit(2)
return DEFAULT_PATH
def _demo() -> int:
"""Write a synthetic 3-round journey to a temp log and read it back — no token/network."""
import tempfile
path = os.path.join(tempfile.gettempdir(), "fyers_supercharge_demo_evolution.jsonl")
# fresh start
try:
os.remove(path)
except OSError:
pass
card = {"sharpe": 1.3, "max_drawdown": -0.12, "return_pct": 0.14, "win_rate": 0.55}
log_round(1, "risk_adjusted", leader="baseline", notes="baseline captured", path=path)
log_variant(1, "v1_tighter_stop", card, "accepted", objective="low_drawdown",
rules_changed=["stop: 2%->1.2%"], rules_preserved=["entry SMA cross"],
reason="cut drawdown 3pts, Sharpe held", path=path)
log_decision(1, "Risk vs Return on stop tightness",
disagreements=["Return Strategist: fewer winners let run"],
resolved=True, path=path)
log_round(2, "low_drawdown", leader="v1_tighter_stop", path=path)
log_variant(2, "v2_vol_target", card, "rejected", objective="low_drawdown",
reason="robustness_score 0.1 — overfit to 2023 vol", path=path)
log_round(3, "low_drawdown", leader="v1_tighter_stop", path=path)
rep = checkpoint_report(3, path=path)
log_checkpoint(3, rep, user_choice="focus_drawdown", path=path)
print(f"log written: {path}\n")
print("summary:")
print(json.dumps(summary(path), indent=2))
print("\ncheckpoint_report(round 3):")
print(json.dumps(rep, indent=2))
print(f"\ntimeline: {len(timeline(path))} records")
return 0
def _cli() -> int:
args = sys.argv[1:]
if not args:
print(__doc__)
return 0
cmd = args[0]
if cmd == "demo":
return _demo()
path = _arg_path(args)
if cmd == "timeline":
recs = timeline(path)
if not recs:
print(f"(no records in {path})")
for r in recs:
print(json.dumps(r, indent=2))
return 0
if cmd == "summary":
print(json.dumps(summary(path), indent=2))
return 0
if cmd == "tail":
n = 20
if "--n" in args:
try:
n = int(args[args.index("--n") + 1])
except (IndexError, ValueError):
print("--n requires an integer", file=sys.stderr)
return 2
recs = timeline(path)
recs = recs[-n:] if len(recs) > n else recs
if not recs:
print(f"(no records in {path})")
for r in recs:
print(json.dumps(r, indent=2))
return 0
print(f"unknown command: {cmd!r} (use: demo | timeline | summary | tail)", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(_cli())
scripts/scorecard.py
#!/usr/bin/env python3
"""Standardized variant scorecard for Supercharge Mode (stdlib-first).
Turns one strategy variant's backtest output into the SINGLE, uniform metric dict that the
comparison dashboard ranks on. Every variant — and the immutable baseline — is scored by this
exact function so the numbers are comparable (that is the whole point of the optimization loop).
TWO INPUTS, per `references/variants.md`'s variant contract:
returns : the per-bar returns series a backtest produces (fyers-trading's `df["strat_ret"]`,
see that skill's references/backtesting.md §2). A pandas Series, list, or ndarray.
trades : the list of closed trades — [{"pnl": float, ...}, ...]. Needed for the trade-level
metrics QuantStats structurally cannot compute (it never sees "trades").
REUSES fyers-trading's engine when available. The risk metrics (Sharpe, Sortino, max drawdown,
CAGR, Calmar, annualized vol, positive-period %) come from fyers-trading's
`scripts/quantstats_report.py` via `key_metrics()` — the SAME code that scored the baseline — so
a variant is never measured by a different yardstick. If that skill isn't importable (supercharge
installed without fyers-trading, or QuantStats not installed), we fall back to a small pure-stdlib
computation of Sharpe/Sortino/max-drawdown so this script still runs and its `demo` always works.
Full-fidelity scoring needs fyers-trading + QuantStats installed — see the skill's SKILL.md.
TWO WIN RATES (do not conflate — mirrors fyers-trading/references/quantstats.md):
win_rate — TRADE-LEVEL: winning trades / total trades. Computed here from `trades`.
positive_period_pct — QuantStats' % of positive PERIODS (e.g. days). From the returns series.
They answer different questions; the scorecard reports both under distinct keys.
Functions
---------
scorecard(returns, trades=None, capital=None, rf=0.0, periods_per_year=252,
oos_fraction=0.3, capital_utilization=None, execution_risk=None) -> dict
trade_metrics(trades, capital=None) -> dict just the trade-level figures
robustness_score(returns, oos_fraction=0.3, ...) -> float in/out-of-sample Sharpe stability
Usage
-----
from scorecard import scorecard
card = scorecard(variant_df["strat_ret"], trades=variant_trades, capital=100_000)
CLI
---
python scorecard.py demo # synthetic returns + trades -> a full scorecard, no token/network
"""
from __future__ import annotations
import math
import sys
# The dashboard-facing key order (see references/metrics.md). Kept as the canonical schema so
# every scorecard dict has the same shape even when some fields are None (not derivable).
SCORECARD_KEYS = [
"net_profit", "return_pct", "profit_factor", "sharpe", "sortino",
"win_rate", "avg_profit", "avg_loss", "max_drawdown", "trade_count",
"return_on_capital", "capital_utilization", "largest_loss",
"longest_losing_streak", "execution_risk", "robustness_score",
# Reported alongside but deliberately distinct from win_rate:
"positive_period_pct", "cagr", "calmar", "volatility_ann",
]
# ---------------------------------------------------------------------------
# fyers-trading reuse (with graceful fallback)
# ---------------------------------------------------------------------------
def _load_quantstats():
"""Return fyers-trading's `(key_metrics, daily_returns)`, or `(None, None)` if unavailable.
Tries a plain import first (works when the host puts both skills' scripts/ on sys.path),
then probes for a sibling `fyers-trading/scripts` directory next to this skill and imports
from there. Any failure (missing skill, missing QuantStats) returns (None, None) -> fallback.
Both functions are returned together because scoring must mirror fyers-trading's documented
flow: `daily_returns()` FIRST (resample intraday->daily, stamp an IST index), THEN
`key_metrics()` — otherwise annualized Sharpe/drawdown/CAGR on an intraday bar series are
wrong and the variant wouldn't be measured on the same yardstick as the baseline.
"""
try:
from quantstats_report import key_metrics, daily_returns # type: ignore
return key_metrics, daily_returns
except Exception: # noqa: BLE001 — fall through to sibling-path probe
pass
import os
here = os.path.dirname(os.path.abspath(__file__))
# this file: .../skills/fyers-supercharge/scripts/scorecard.py
# sibling: .../skills/fyers-trading/scripts
candidates = [
os.path.normpath(os.path.join(here, "..", "..", "fyers-trading", "scripts")),
]
for cand in candidates:
qs = os.path.join(cand, "quantstats_report.py")
if os.path.isfile(qs):
if cand not in sys.path:
sys.path.insert(0, cand)
try:
from quantstats_report import key_metrics, daily_returns # type: ignore
return key_metrics, daily_returns
except Exception: # noqa: BLE001 — e.g. QuantStats not installed
return None, None
return None, None
# ---------------------------------------------------------------------------
# Pure-stdlib returns helpers (fallback path — no numpy/pandas required)
# ---------------------------------------------------------------------------
def _as_floats(returns) -> list[float]:
"""Coerce a pandas Series / list / ndarray of per-bar returns into a clean float list."""
try:
values = list(returns.values) # pandas Series / ndarray
except AttributeError:
values = list(returns)
out: list[float] = []
for v in values:
try:
f = float(v)
except (TypeError, ValueError):
continue
if math.isfinite(f):
out.append(f)
return out
def _mean(xs: list[float]) -> float:
return sum(xs) / len(xs) if xs else 0.0
def _std(xs: list[float]) -> float:
"""Sample standard deviation (ddof=1)."""
n = len(xs)
if n < 2:
return 0.0
m = _mean(xs)
var = sum((x - m) ** 2 for x in xs) / (n - 1)
return math.sqrt(var)
def _sharpe(rets: list[float], rf: float, ppy: int) -> float:
"""Annualized Sharpe from a periodic returns list (rf is the annual risk-free rate)."""
if len(rets) < 2:
return 0.0
per_period_rf = rf / ppy
excess = [r - per_period_rf for r in rets]
sd = _std(excess)
if sd == 0:
return 0.0
return (_mean(excess) / sd) * math.sqrt(ppy)
def _sortino(rets: list[float], rf: float, ppy: int) -> float:
"""Annualized Sortino: uses downside deviation (negative excess returns only)."""
if len(rets) < 2:
return 0.0
per_period_rf = rf / ppy
excess = [r - per_period_rf for r in rets]
downside = [min(0.0, e) for e in excess]
dd = math.sqrt(sum(d * d for d in downside) / len(downside)) if downside else 0.0
if dd == 0:
return 0.0
return (_mean(excess) / dd) * math.sqrt(ppy)
def _max_drawdown(rets: list[float]) -> float:
"""Max drawdown of the compounded equity curve, as a negative fraction (e.g. -0.23)."""
if not rets:
return 0.0
equity = 1.0
peak = 1.0
mdd = 0.0
for r in rets:
equity *= (1.0 + r)
peak = max(peak, equity)
if peak > 0:
dd = equity / peak - 1.0
mdd = min(mdd, dd)
return mdd
def _fallback_risk_metrics(returns, rf: float, ppy: int) -> dict:
"""Stdlib risk metrics matching key_metrics()'s keys when fyers-trading isn't available.
Assumes the returns are at a REGULAR periodic frequency (default daily, ppy=252) — the same
assumption QuantStats makes. cagr/calmar are left None here (they need calendar dates, which
the full quantstats_report path supplies); the dashboard tolerates None."""
rets = _as_floats(returns)
mdd = _max_drawdown(rets)
positive = sum(1 for r in rets if r > 0)
return {
"sharpe": round(_sharpe(rets, rf, ppy), 6),
"sortino": round(_sortino(rets, rf, ppy), 6),
"max_drawdown": round(mdd, 6),
"cagr": None,
"calmar": None,
"volatility_ann": round(_std(rets) * math.sqrt(ppy), 6),
"positive_period_pct": round(positive / len(rets), 6) if rets else 0.0,
}
def _risk_metrics(returns, rf: float, ppy: int, freq: str = "auto") -> tuple:
"""Risk metrics via fyers-trading's key_metrics() if possible, else stdlib fallback.
Mirrors fyers-trading/references/backtesting.md's flow: normalize with `daily_returns(freq)`
FIRST (intraday bar returns -> compounded daily, IST-indexed), THEN `key_metrics()`. Skipping
the normalization would annualize an intraday `strat_ret` series wrongly, so a variant would
be scored on a different yardstick than the baseline. Pass freq="D" when the returns are
already daily to skip resampling. Returns (metrics_dict, used_quantstats)."""
key_metrics, daily_returns = _load_quantstats()
if key_metrics is not None and daily_returns is not None:
try:
rets = daily_returns(returns, freq=freq) # normalize exactly like the baseline
return dict(key_metrics(rets, rf=rf)), True
except Exception: # noqa: BLE001 — bad/short series, missing dep at call time, etc.
pass
return _fallback_risk_metrics(returns, rf, ppy), False
# ---------------------------------------------------------------------------
# Trade-level metrics (QuantStats cannot compute these — it never sees trades)
# ---------------------------------------------------------------------------
def trade_metrics(trades: "list[dict] | None", capital: "float | None" = None) -> dict:
"""Compute trade-level figures from a list of closed trades.
Each trade is a dict with at least a ``pnl`` (float, in currency). Returns a dict with:
net_profit, profit_factor, win_rate (winning/total), avg_profit (avg win), avg_loss
(avg loss, negative), trade_count, largest_loss, longest_losing_streak, return_on_capital.
Missing/empty trades -> a dict of zeros/None (still shaped for the dashboard).
"""
keys = ["net_profit", "profit_factor", "win_rate", "avg_profit", "avg_loss",
"trade_count", "largest_loss", "longest_losing_streak", "return_on_capital"]
empty = {k: (0 if k in ("trade_count", "longest_losing_streak") else None) for k in keys}
if not trades:
return empty
pnls: list[float] = []
for t in trades:
try:
pnls.append(float(t["pnl"]))
except (KeyError, TypeError, ValueError):
continue
if not pnls:
return empty
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_profit = sum(wins)
gross_loss = abs(sum(losses))
net_profit = sum(pnls)
# Longest consecutive losing streak.
longest = streak = 0
for p in pnls:
if p < 0:
streak += 1
longest = max(longest, streak)
else:
streak = 0
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else None # None = no losses
return {
"net_profit": round(net_profit, 4),
"profit_factor": round(profit_factor, 4) if profit_factor is not None else None,
"win_rate": round(len(wins) / len(pnls), 6),
"avg_profit": round(gross_profit / len(wins), 4) if wins else None,
"avg_loss": round(sum(losses) / len(losses), 4) if losses else None,
"trade_count": len(pnls),
"largest_loss": round(min(pnls), 4),
"longest_losing_streak": longest,
"return_on_capital": round(net_profit / capital, 6) if capital else None,
}
# ---------------------------------------------------------------------------
# Robustness (backs the Backtest-Integrity seat & the Robustness Score)
# ---------------------------------------------------------------------------
def robustness_score(returns, oos_fraction: float = 0.3, rf: float = 0.0,
periods_per_year: int = 252) -> float:
"""A 0..1 stability score: how well in-sample edge survives out-of-sample.
Splits the returns chronologically into in-sample (first 1-oos_fraction) and out-of-sample
(last oos_fraction), and compares Sharpe. score = clamp(oos_sharpe / is_sharpe, 0, 1) when
the in-sample Sharpe is positive; 0 otherwise. A curve-fit strategy whose edge vanishes
out-of-sample scores near 0; one that holds up scores near 1. Deliberately simple and
explainable — see references/overfitting.md for the fuller guardrails this summarizes.
"""
rets = _as_floats(returns)
if len(rets) < 4:
return 0.0
split = int(len(rets) * (1.0 - oos_fraction))
in_sample, oos = rets[:split], rets[split:]
if len(in_sample) < 2 or len(oos) < 2:
return 0.0
is_sharpe = _sharpe(in_sample, rf, periods_per_year)
oos_sharpe = _sharpe(oos, rf, periods_per_year)
if is_sharpe <= 0:
return 0.0
return round(max(0.0, min(1.0, oos_sharpe / is_sharpe)), 6)
# ---------------------------------------------------------------------------
# The scorecard
# ---------------------------------------------------------------------------
def scorecard(returns, trades: "list[dict] | None" = None, capital: "float | None" = None,
rf: float = 0.0, periods_per_year: int = 252, oos_fraction: float = 0.3,
freq: str = "auto", capital_utilization: "float | None" = None,
execution_risk: "float | None" = None) -> dict:
"""Score one variant into the uniform dashboard dict (keys = SCORECARD_KEYS).
Args:
returns: per-bar returns series (pandas Series / list / ndarray) — the variant contract.
trades: list of closed-trade dicts with a ``pnl`` field (for trade-level metrics).
capital: starting capital, for return_on_capital / return_pct.
rf: annual risk-free rate (e.g. 0.07).
periods_per_year: annualization factor for the stdlib fallback (252 = daily).
oos_fraction: out-of-sample tail fraction for the robustness score.
freq: normalization frequency passed to fyers-trading's `daily_returns()` before
scoring — "auto" (default) resamples intraday bar returns down to daily (matching the
baseline), "D" if the returns are already daily. Every variant and the baseline must
use the SAME freq so the comparison stays apples-to-apples.
capital_utilization / execution_risk: optional caller-supplied estimates (the Execution
and Capital-Efficiency seats provide these; they are NOT derivable from returns alone,
so they default to None rather than being fabricated).
The returned dict always has every key in SCORECARD_KEYS (None where a figure isn't
available), plus a `_meta` sub-dict noting whether fyers-trading's QuantStats was used.
"""
risk, used_qs = _risk_metrics(returns, rf, periods_per_year, freq=freq)
tm = trade_metrics(trades, capital=capital)
net_profit = tm.get("net_profit")
return_pct = round(net_profit / capital, 6) if (capital and net_profit is not None) else None
card = {
"net_profit": net_profit,
"return_pct": return_pct,
"profit_factor": tm.get("profit_factor"),
"sharpe": risk.get("sharpe"),
"sortino": risk.get("sortino"),
"win_rate": tm.get("win_rate"),
"avg_profit": tm.get("avg_profit"),
"avg_loss": tm.get("avg_loss"),
"max_drawdown": risk.get("max_drawdown"),
"trade_count": tm.get("trade_count"),
"return_on_capital": tm.get("return_on_capital"),
"capital_utilization": capital_utilization,
"largest_loss": tm.get("largest_loss"),
"longest_losing_streak": tm.get("longest_losing_streak"),
"execution_risk": execution_risk,
"robustness_score": robustness_score(returns, oos_fraction, rf, periods_per_year),
"positive_period_pct": risk.get("positive_period_pct"),
"cagr": risk.get("cagr"),
"calmar": risk.get("calmar"),
"volatility_ann": risk.get("volatility_ann"),
}
card["_meta"] = {
"scored_with_quantstats": used_qs,
"periods_per_year": periods_per_year,
"rf": rf,
"freq": freq,
}
return card
# ---------------------------------------------------------------------------
# CLI demo (no token / no network)
# ---------------------------------------------------------------------------
def _demo() -> int:
"""Synthetic returns + trades -> a full scorecard. Runs with or without QuantStats."""
import json
import random
rng = random.Random(7)
# ~1 year of daily returns with slight positive drift.
rets = [rng.gauss(0.0006, 0.011) for _ in range(252)]
# A handful of synthetic closed trades.
trades = [{"pnl": rng.gauss(120, 400)} for _ in range(60)]
card = scorecard(rets, trades=trades, capital=100_000, rf=0.07)
used = card["_meta"]["scored_with_quantstats"]
print(f"scored_with_quantstats = {used} "
f"({'reused fyers-trading key_metrics()' if used else 'stdlib fallback'})")
print(json.dumps(card, indent=2))
print("\nnote: win_rate is TRADE-LEVEL; positive_period_pct is % of positive DAYS — "
"distinct figures, never conflate them (see references/metrics.md).")
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "demo":
raise SystemExit(_demo())
print(__doc__)