Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,18 @@ Feed a CSV broker export (同花顺 / 东财 / 富途 / generic), and the agent
5. `scan_shadow_signals` — list today's symbols that match your shadow's entry cadence (research only).

### Backtesting
Create and run quantitative strategies across 7 engines (ChinaA, GlobalEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 18 market-data sources (auto-detect + ordered fallback):
Create and run quantitative strategies across 8 engines (ChinaA, GlobalEquity, IndiaEquity, Crypto, ChinaFutures, GlobalFutures, Forex + options) with 19 market-data sources (auto-detect + ordered fallback):
- **HK/US equities** via yfinance / stooq / yahoo (free, no API key)
- **India equities (NSE/BSE)** via yahoo / yfinance using `<SYMBOL>.NS` (NSE, e.g. `RELIANCE.NS`) or `<SCRIP>.BO` (BSE, e.g. `500325.BO`) — free, no API key. The `IndiaEquityEngine` models T+1 delivery, no overnight shorts (set `allow_short` for intraday), configurable circuit bands, 1-share lots, and the STT/stamp-duty/exchange/GST cost stack. Optionally back-fill from your live broker via the `india_broker` source (Shoonya/Dhan; requires broker login).
- **Cryptocurrency** via OKX or CCXT/100+ exchanges (free, no API key)
- **China A-shares** via AKShare / baostock / tencent / sina / eastmoney / mootdx (free, no API key) — `TUSHARE_TOKEN` optional for premium quality
- **Futures, forex, macro** via AKShare (free, no API key)
- **HK & A-share equities** via Futu (broker login required, optional)
- **Local CSV/parquet bars** via the `local` loader (offline, no network)
- **Premium US data** via optional-key finnhub / alphavantage / tiingo / fmp (graceful fallback to free sources)

Factors: the Alpha101 and QLib158 zoos are tagged for the `equity_in` universe, so they compute on NSE/BSE bars (the GTJA191 zoo stays China-only). Live/paper India trading uses the Shoonya / Dhan connectors (paper + read-only live; live order placement is structurally disabled because those brokers expose no paper/live switch).

Example workflow:
1. Use `list_skills()` to discover strategy patterns
2. Use `load_skill("strategy-generate")` for the strategy creation guide
Expand Down
3 changes: 3 additions & 0 deletions agent/backtest/engines/_market_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
(re.compile(r"^(51|15|56)\d{4}\.(SZ|SH)$", re.I), "a_share"),
(re.compile(r"^[A-Z]+\.US$", re.I), "us_equity"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "hk_equity"),
# India equities: NSE (RELIANCE.NS) / BSE (500325.BO); tickers may carry
# '&' and '-' (e.g. M&M.NS, BAJAJ-AUTO.NS).
(re.compile(r"^[A-Z0-9&.\-]+\.(NS|BO)$", re.I), "india_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
# China futures: product+delivery.exchange (e.g. IF2406.CFFEX, rb2410.SHFE)
Expand Down
3 changes: 3 additions & 0 deletions agent/backtest/engines/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def _build_rule_engines(config: dict, codes: List[str]) -> Dict[str, BaseEngine]
elif market == "hk_equity":
from backtest.engines.global_equity import GlobalEquityEngine
engines["hk_equity"] = GlobalEquityEngine(config, market="hk")
elif market == "india_equity":
from backtest.engines.india_equity import IndiaEquityEngine
engines["india_equity"] = IndiaEquityEngine(config)
elif market == "crypto":
from backtest.engines.crypto import CryptoEngine
engines["crypto"] = CryptoEngine(config)
Expand Down
3 changes: 3 additions & 0 deletions agent/backtest/engines/global_equity.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
- Stamp tax 0.1% bilateral + levies
- Lot-size rounding (simplified to 100 shares)
- Higher slippage than US

India (NSE/BSE) is handled by the dedicated ``backtest.engines.india_equity``
``IndiaEquityEngine`` (T+1 delivery, circuit bands, STT/stamp/GST stack).
"""

from __future__ import annotations
Expand Down
145 changes: 145 additions & 0 deletions agent/backtest/engines/india_equity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""India equity (NSE / BSE) backtest engine.

Models the Indian cash-equity **delivery** segment on daily bars. Intraday
(MIS) mechanics are not represented by a daily-bar engine, so the defaults
reflect overnight delivery rules; the knobs below let advanced users approximate
intraday behaviour.

Market rules:
- T+1 settlement: shares bought today cannot be sold the same bar (delivery).
- No short selling by default: retail cannot hold overnight short delivery
positions. Set ``allow_short=True`` to model intraday (MIS) shorting.
- Circuit bands: per-scrip price bands vary (2/5/10/20%); the exact band is
not derivable from the symbol alone, so a single configurable band applies
(default ±20%, the widest common band). Set ``price_limit`` to ``0`` /
``None`` to disable.
- Lot size: 1 share for cash equity (F&O lot sizes are not modelled here).

Cost stack (delivery, discount-broker defaults; all config-driven). NOTE: SEBI/
exchange tariffs change periodically — verify ``in_*`` rates against a current
broker schedule before relying on absolute cost figures:
- Brokerage: ₹0 (delivery on discount brokers) [in_brokerage]
- STT: 0.1% on buy + 0.1% on sell (bilateral) [in_stt]
- Exchange transaction charge: NSE ~0.00297% (bilateral) [in_exchange_txn]
- SEBI turnover fee: ₹10/crore = 0.0001% (bilateral) [in_sebi_fee]
- Stamp duty: 0.015% on buy only [in_stamp_duty]
- GST: 18% on (brokerage + exchange txn + SEBI fee) [in_gst]
- DP charge: flat per-scrip on sell (default ₹0) [in_dp_charge]
"""

from __future__ import annotations

import pandas as pd

from backtest.engines.base import BaseEngine
from backtest.engines.china_a import _calc_pct_change


class IndiaEquityEngine(BaseEngine):
"""NSE / BSE cash-equity (delivery) engine.

Config keys (all optional; defaults shown in the module docstring):
- allow_short: bool, default False
- price_limit: float fraction or None, default 0.20
- slippage: default 0.001
- in_brokerage / in_stt / in_exchange_txn / in_sebi_fee /
in_stamp_duty / in_gst / in_dp_charge
"""

def __init__(self, config: dict):
config = {**config, "leverage": 1.0} # cash delivery: no leverage
super().__init__(config)
self.allow_short: bool = bool(config.get("allow_short", False))
self.price_limit = config.get("price_limit", 0.20)
self.slippage_rate: float = config.get("slippage", 0.001)
# Cost stack
self.in_brokerage: float = config.get("in_brokerage", 0.0)
self.in_stt: float = config.get("in_stt", 0.001)
self.in_exchange_txn: float = config.get("in_exchange_txn", 0.0000297)
self.in_sebi_fee: float = config.get("in_sebi_fee", 0.000001)
self.in_stamp_duty: float = config.get("in_stamp_duty", 0.00015)
self.in_gst: float = config.get("in_gst", 0.18)
self.in_dp_charge: float = config.get("in_dp_charge", 0.0)

def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""India delivery execution rules.

Args:
symbol: NSE/BSE symbol (e.g. ``RELIANCE.NS``).
direction: 1 (buy), -1 (short), 0 (sell/close).
bar: Current bar (needs ``close`` + ``pre_close``/``pct_chg`` for
circuit checks).

Returns:
True if the trade is allowed.
"""
# 1. Short selling: blocked unless explicitly modelling intraday (MIS).
if direction == -1 and not self.allow_short:
return False

# 2. T+1: can't sell shares bought today (delivery).
if direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
bar_date = _bar_date(bar)
entry_date = pos.entry_time.date() if hasattr(pos.entry_time, "date") else None
if bar_date is not None and entry_date is not None and bar_date == entry_date:
return False

# 3. Circuit bands (single configurable band; disabled when falsy).
if self.price_limit:
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
limit = float(self.price_limit)
if direction == 1 and pct_chg >= limit - 0.001:
return False # upper circuit: can't buy
if direction == 0 and pct_chg <= -limit + 0.001:
return False # lower circuit: can't sell

return True

def round_size(self, raw_size: float, price: float) -> float:
"""Cash equity trades in 1-share lots."""
return float(max(int(raw_size), 0))

def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""India delivery cost stack (see module docstring).

``_direction`` is unused — reserved for future asymmetric long/short
(intraday MIS) schedules.
"""
notional = size * price
brokerage = notional * self.in_brokerage
exchange_txn = notional * self.in_exchange_txn # bilateral
sebi_fee = notional * self.in_sebi_fee # bilateral
gst = (brokerage + exchange_txn + sebi_fee) * self.in_gst
stt = notional * self.in_stt # bilateral (delivery)
comm = brokerage + exchange_txn + sebi_fee + gst + stt
if is_open:
comm += notional * self.in_stamp_duty # stamp duty: buy-only
else:
comm += self.in_dp_charge # DP charge: sell-only, flat
return comm

def apply_slippage(self, price: float, direction: int) -> float:
"""India slippage (configurable)."""
return price * (1 + direction * self.slippage_rate)


# ── Helpers ──


def _bar_date(bar: pd.Series):
"""Extract date from bar, handling various column names."""
for col in ("trade_date", "date"):
if col in bar.index:
val = bar[col]
if hasattr(val, "date"):
return val.date()
try:
return pd.Timestamp(val).date()
except Exception:
pass
if hasattr(bar, "name") and hasattr(bar.name, "date"):
return bar.name.date()
return None
175 changes: 175 additions & 0 deletions agent/backtest/loaders/india_broker_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""India broker data bridge: feed Shoonya / Dhan history into the backtest layer.

The Shoonya (Finvasia) and Dhan connectors already expose live-account market
data via ``get_historical_bars`` (read path). This loader adapts that envelope
into the standard OHLCV frame so a user's *broker* history can back the same
backtests as the public Yahoo feed — useful when matching a live account exactly
or pulling symbols Yahoo lacks.

It is an OPT-IN source (``requires_auth``): ``is_available`` is True only when a
broker SDK is importable AND a broker is configured, so it trails Yahoo/yfinance
in the ``india_equity`` fallback chain and never fires in CI / unconfigured runs.

Symbol convention: project ``RELIANCE.NS`` / ``500325.BO`` → broker ``RELIANCE``
on exchange ``NSE`` / ``BSE`` (the suffix selects the exchange; the base symbol
is passed bare).

Limitation: broker endpoints return a bounded window of *recent* bars (period +
limit), not an arbitrary historical start. This loader requests enough bars to
cover the window and clips to ``[start_date, end_date]``; very old ranges may
come back short. For deep history prefer Yahoo.
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional

import pandas as pd

from backtest.loaders.base import validate_date_range
from backtest.loaders.registry import register

logger = logging.getLogger(__name__)

_OUTPUT_COLUMNS = ["open", "high", "low", "close", "volume"]
# Project interval -> broker ``period`` token (both connectors share this set).
_PERIOD_MAP = {"1D": "1d", "1H": "1h", "5m": "5m", "15m": "15m", "30m": "30m", "1m": "1m"}


def _resolve_broker():
"""Return ``(broker_key, sdk_module)`` for the first available India broker.

Prefers Shoonya, then Dhan. Returns ``(None, None)`` when neither the SDK
nor a config is present. Import is deferred and defensive so a missing
``src.trading`` package or broker SDK simply means "unavailable", never a
crash in the loader registry.
"""
try:
from src.trading.connectors.shoonya import sdk as shoonya_sdk

if shoonya_sdk.shoonya_available():
return "shoonya", shoonya_sdk
except Exception as exc: # noqa: BLE001 — optional dependency / config
logger.debug("shoonya bridge unavailable: %s", exc)
try:
from src.trading.connectors.dhan import sdk as dhan_sdk

if dhan_sdk.dhan_available():
return "dhan", dhan_sdk
except Exception as exc: # noqa: BLE001
logger.debug("dhan bridge unavailable: %s", exc)
return None, None


def _exchange_for(code: str) -> str:
"""Map the project suffix to the broker exchange code (NSE / BSE)."""
return "BSE" if code.strip().upper().endswith(".BO") else "NSE"


def _base_symbol(code: str) -> str:
"""Strip the ``.NS`` / ``.BO`` suffix, leaving the broker's bare symbol."""
cleaned = code.strip()
upper = cleaned.upper()
if upper.endswith((".NS", ".BO")):
return cleaned[:-3]
return cleaned


def _bars_to_frame(bars: list[dict], start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Convert the broker ``bars`` list into a clipped OHLCV frame, or ``None``."""
if not bars:
return None
frame = pd.DataFrame(bars)
if "time" not in frame.columns:
return None
# ``time`` is epoch seconds (Shoonya ssboe / Dhan candle[0]) or an ISO string.
ts = pd.to_numeric(frame["time"], errors="coerce")
if ts.notna().any():
index = pd.to_datetime(ts, unit="s", errors="coerce")
else:
index = pd.to_datetime(frame["time"], errors="coerce")
frame = frame.drop(columns=["time"])
frame.index = pd.DatetimeIndex(index).tz_localize(None)
frame.index.name = "trade_date"

for col in _OUTPUT_COLUMNS:
if col not in frame.columns:
frame[col] = 0.0 if col == "volume" else pd.NA
frame = frame[_OUTPUT_COLUMNS].apply(pd.to_numeric, errors="coerce")
frame = frame.dropna(subset=["open", "high", "low", "close"]).sort_index()

lower = pd.Timestamp(start_date).normalize()
upper = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
frame = frame[(frame.index >= lower) & (frame.index < upper)]
return frame.astype(float) if not frame.empty else None


@register
class DataLoader:
"""Shoonya / Dhan history adapter for the ``india_equity`` market."""

name = "india_broker"
markets = {"india_equity"}
requires_auth = True

def __init__(self) -> None:
pass

def is_available(self) -> bool:
"""True only when an India broker SDK is importable and configured."""
broker, _ = _resolve_broker()
return broker is not None

def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
*,
interval: str = "1D",
fields: Optional[List[str]] = None,
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV history for ``codes`` from the configured India broker."""
del fields
if not codes:
return {}
validate_date_range(start_date, end_date)

broker, sdk = _resolve_broker()
if sdk is None:
return {}

period = _PERIOD_MAP.get(str(interval).strip(), "1d")
# Request enough bars to cover the window (business days + headroom).
span_days = max((pd.Timestamp(end_date) - pd.Timestamp(start_date)).days, 1)
limit = min(max(span_days, 30), 2000)

result: Dict[str, pd.DataFrame] = {}
for code in codes:
try:
envelope = sdk.get_historical_bars(
_base_symbol(code),
exchange=_exchange_for(code),
period=period,
limit=limit,
)
except TypeError:
# Dhan uses ``exchange_segment``; retry without the ``exchange`` kw.
try:
envelope = sdk.get_historical_bars(
_base_symbol(code), period=period, limit=limit
)
except Exception as exc: # noqa: BLE001 — one bad symbol never aborts
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
continue
except Exception as exc: # noqa: BLE001
logger.warning("%s bridge failed for %s: %s", broker, code, exc)
continue

if not isinstance(envelope, dict) or str(envelope.get("status", "")).lower() != "ok":
continue
frame = _bars_to_frame(envelope.get("bars", []), start_date, end_date)
if frame is not None and not frame.empty:
result[code] = frame
return result
3 changes: 3 additions & 0 deletions agent/backtest/loaders/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"alphavantage",
"tiingo",
"fmp",
"india_broker",
"local",
"auto",
}
Expand Down Expand Up @@ -92,6 +93,7 @@ def _ensure_registered() -> None:
"backtest.loaders.alphavantage_loader",
"backtest.loaders.tiingo_loader",
"backtest.loaders.fmp_loader",
"backtest.loaders.india_broker_loader",
"backtest.loaders.local_loader",
]
import importlib
Expand Down Expand Up @@ -125,6 +127,7 @@ def _ensure_registered() -> None:
"a_share": ["tencent", "mootdx", "eastmoney", "baostock", "akshare", "tushare", "local"],
"us_equity": ["yahoo", "stooq", "sina", "eastmoney", "yfinance", "tiingo", "fmp", "finnhub", "alphavantage", "akshare", "local"],
"hk_equity": ["eastmoney", "yahoo", "futu", "yfinance", "akshare", "local"],
"india_equity": ["yahoo", "yfinance", "india_broker", "local"],
"crypto": ["okx", "ccxt", "yfinance", "local"],
"futures": ["tushare", "akshare", "local"],
"fund": ["tushare", "akshare", "local"],
Expand Down
Loading