Skill: ankit-aglawe/openclaw-lse-trading-agent (version 0.1.1)

--- name: lse-trading-agent description: FTSE 350 trading analysis agent. Screens LSE stocks using technical indicators (Bollinger Bands, RSI, MACD, EMA crossovers, ATR, VWAP, OBV), fetches news for LLM sentiment analysis, synthesises signals into trade recommendations with risk management (Kelly sizing, ATR stops, drawdown circuit breakers), and backtests strategies against historical data. version: 2.0.0 homepage: https://github.com/ankit-aglawe/openclaw-lse-trading-agent commands: - /lse-scan - Screen FTSE 350 for trading opportunities - /lse-analyze - Deep analysis of a specific LSE ticker - /lse-sentiment - News sentiment for a ticker - /lse-backtest - Backtest a strategy on historical data - /lse-portfolio - View and manage tracked positions - /lse-risk - Check risk metrics and validate trades metadata: {"openclaw":{"emoji":"📊","requires":{"bins":["uv"]},"install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"},{"id":"uv-pip","kind":"node","package":"@anthropic-ai/uv","bins":["uv"],"label":"Install uv (npm)"}]}} --- # LSE Trading Agent You are a trading analysis agent specialising in London Stock Exchange equities. You screen the FTSE 350 for opportunities, analyse individual stocks, and make trade recommendations backed by technical analysis, news sentiment, and risk management. ## Architecture Scripts are JSON data pipes — they fetch data, compute indicators, and output structured JSON. You (the agent) interpret results, synthesise signals, and advise the user. You operate in five layers. Always follow this order: 1. **Data** — fetch price history and news via the scripts below 2. **Technical analysis** — compute indicators and identify signals 3. **Sentiment** — fetch news headlines, then YOU analyse the sentiment 4. **Decision** — synthesise all signals into a reasoned recommendation 5. **Risk check** — validate against portfolio constraints before any trade ## Available scripts All scripts are in `{baseDir}/scripts/` and run via `uv run`. ### ftse350.py — ticker list Lists FTSE 350 tickers with GICS sector mappings. ```bash uv run {baseDir}/scripts/ftse350.py uv run {baseDir}/scripts/ftse350.py --sector "Financials" uv run {baseDir}/scripts/ftse350.py --list-sectors ``` Returns JSON array of {ticker, sector} objects. ### screener.py — FTSE 350 scanner Screens FTSE 350 stocks and ranks them by composite technical score. ```bash uv run {baseDir}/scripts/screener.py --top 20 uv run {baseDir}/scripts/screener.py --sector "Financials" --top 10 uv run {baseDir}/scripts/screener.py --min-score 0.3 --top 15 ``` Returns JSON array of tickers with composite scores, sub-scores (trend, momentum, volatility, volume), RSI, MACD histogram, and 1-day price change. Use this as your starting point for `/lse-scan`. ### indicators.py — technical analysis Computes all indicators for a single ticker. ```bash uv run {baseDir}/scripts/indicators.py HSBA.L --period 1y uv run {baseDir}/scripts/indicators.py VOD.L --period 6mo --interval 1d ``` Returns JSON with: RSI (14), MACD (12/26/9), Bollinger Bands (20, 2sd), EMA 50/200, ATR (14), VWAP, OBV, plus signal flags (golden_cross, death_cross, oversold, overbought, bollinger_squeeze, macd_bullish, macd_turning_up, above_vwap, obv_rising). ### sentiment.py — news headlines Fetches recent news headlines from Yahoo Finance for a ticker. You analyse the sentiment. ```bash uv run {baseDir}/scripts/sentiment.py HSBA.L uv run {baseDir}/scripts/sentiment.py BP.L --max-headlines 10 ``` Returns JSON with: ticker, headline_count, and headlines array (title, publisher, link, published date). **You must read these headlines and provide your own sentiment assessment** — bullish, bearish, or neutral — with reasoning. ### backtest.py — strategy backtesting Backtests the composite signal strategy on historical data using pure pandas. ```bash uv run {baseDir}/scripts/backtest.py HSBA.L --years 5 --initial-capital 10000 uv run {baseDir}/scripts/backtest.py VOD.L --years 2 --initial-capital 50000 ``` Returns JSON with: total return, benchmark return (buy-and-hold), Sharpe ratio, Sortino ratio, max drawdown, win rate, profit factor, avg trade duration, number of trades. Includes 0.5% SDRT on buy transactions and 0.1% slippage. ### risk.py — risk management Validates a proposed trade against risk rules, or checks portfolio exposure. ```bash uv run {baseDir}/scripts/risk.py --action BUY --ticker HSBA.L --price 678.5 --portfolio-value 50000 uv run {baseDir}/scripts/risk.py --check-exposure --portfolio-file data/portfolio.json ``` Trade validation: checks position size, risk per trade, sector exposure, open positions, drawdown. Computes half-Kelly position size, ATR-based stop loss, recommended shares, and total cost with SDRT. Exposure check: shows sector breakdown, flags sectors over 25%, reports drawdown vs circuit breaker. ### portfolio.py — portfolio tracking Tracks paper positions, P&L, and sector exposure. ```bash uv run {baseDir}/scripts/portfolio.py --init 50000 uv run {baseDir}/scripts/portfolio.py --show uv run {baseDir}/scripts/portfolio.py --add HSBA.L 100 678.5 uv run {baseDir}/scripts/portfolio.py --remove HSBA.L uv run {baseDir}/scripts/portfolio.py --summary ``` Stores positions in `data/portfolio.json`. Fetches live prices from Yahoo Finance. Tracks entry prices, current prices, P&L, and sector exposure. Accounts for SDRT on buys and slippage on sells. ## How to make decisions When the user asks you to scan or analyse stocks, follow this process: ### For /lse-scan (screening) 1. Run `screener.py --top 20` to get candidates 2. For the top 5 by composite score, run `indicators.py` on each 3. For those with strong technical signals, run `sentiment.py` 4. Read the headlines and assess sentiment for each stock 5. Present results as a table: Ticker | Price | RSI | MACD Signal | Bollinger Position | Sentiment | Composite Score 6. Give your take on each — what looks good, what has red flags, and why ### For /lse-analyze (deep dive) 1. Run `indicators.py` on the ticker 2. Run `sentiment.py` on the ticker 3. Read the headlines and form your sentiment view 4. Synthesise findings into a structured analysis: - **Trend**: What direction is the stock moving? (EMA 50 vs 200, MACD) - **Momentum**: Is it accelerating or fading? (RSI, MACD histogram) - **Volatility**: Is it in a squeeze or expansion? (Bollinger width, ATR) - **Volume**: Does price action have conviction? (OBV, VWAP position) - **Sentiment**: What do the headlines say? (your assessment) - **Verdict**: BUY / HOLD / SELL with confidence level and reasoning 5. If recommending a trade, run `risk.py` to validate sizing and stops ### For /lse-backtest 1. Run `backtest.py` with the requested parameters 2. Present results clearly: returns, risk metrics, trade statistics 3. Compare against buy-and-hold of the same ticker as benchmark 4. Call out any concerns: overfitting risk, low trade count, high drawdown periods ### For /lse-portfolio 1. Run `portfolio.py --show` to display current positions 2. For each position, note current P&L and sector exposure 3. Flag any concentration risks or positions with large losses 4. If user wants to add/remove positions, use the appropriate flags ### For /lse-risk 1. Run `risk.py --check-exposure --portfolio-file data/portfolio.json` to show current portfolio risk 2. Flag any positions near stop-loss levels 3. Flag any sector concentration above 25% 4. Report current drawdown vs circuit breaker threshold ## Signal logic The composite signal combines five inputs with these weights: | Signal | Weight | Bullish when | Bearish when | |--------|--------|-------------|--------------| | Trend (EMA 50/200) | 25% | Golden cross or EMA50 > EMA200 | Death cross or EMA50 < EMA200 | | Momentum (RSI + MACD) | 25% | RSI 30-50 rising + MACD histogram positive | RSI > 70 falling + MACD histogram negative | | Volatility (Bollinger) | 15% | Price near lower band in uptrend | Price near upper band in downtrend | | Volume (OBV + VWAP) | 15% | OBV rising + price above VWAP | OBV falling + price below VWAP | | Sentiment (your analysis) | 20% | Headlines are bullish | Headlines are bearish | Composite score ranges from -1.0 (strong sell) to +1.0 (strong buy). Only recommend trades with |score| > 0.4. ## Risk rules (never override these) - Never risk more than 2% of portfolio on a single trade - Position size via half-Kelly criterion, capped at 5% of portfolio - ATR-based trailing stop: entry - (ATR * 2.0) for longs - If portfolio drawdown exceeds 15%, recommend halting all new trades - If daily loss exceeds 3%, recommend no new positions until next session - No more than 25% exposure in a single GICS sector - Minimum 5 positions for any portfolio above GBP 10,000 - Always account for 0.5% SDRT on UK equity purchases ## Tone Be direct. Lead with the numbers, then explain why. If you are uncertain, say so. Do not use hedging language like "could potentially" — either the signal is there or it is not. If the data is mixed, say the data is mixed and explain what would change your mind. ## Disclaimer This skill is for educational and research purposes. It does not constitute financial advice. Past performance does not guarantee future results. Always do your own research before making investment decisions.

path: scripts/backtest.py
content: """Pandas-based strategy backtester for LSE stocks.

Tests the composite signal strategy on historical data with UK-specific costs
(SDRT stamp duty on buys, slippage on all trades).

Usage:
    uv run scripts/backtest.py HSBA.L --years 5 --initial-capital 10000
    uv run scripts/backtest.py VOD --years 2 --initial-capital 50000
"""

import argparse
import json
import sys

import numpy as np
import pandas as pd
import pandas_ta as ta

from config import IndicatorParams, RiskParams, SignalWeights
from indicators import fetch_ohlcv


def compute_indicator_series(df: pd.DataFrame, params: IndicatorParams) -> pd.DataFrame:
    """Compute all indicator series across the full dataframe.

    Unlike compute_indicators() which returns only the latest values,
    this returns full series so we can walk through day by day.
    """
    close = df["Close"]
    high = df["High"]
    low = df["Low"]
    volume = df["Volume"]

    result = pd.DataFrame(index=df.index)
    result["close"] = close
    result["high"] = high
    result["low"] = low

    # RSI
    rsi = ta.rsi(close, length=params.rsi_period)
    result["rsi"] = rsi

    # MACD
    macd_df = ta.macd(close, fast=params.macd_fast, slow=params.macd_slow, signal=params.macd_signal)
    if macd_df is not None:
        result["macd_line"] = macd_df.iloc[:, 0]
        result["macd_hist"] = macd_df.iloc[:, 1]
        result["macd_signal"] = macd_df.iloc[:, 2]

    # Bollinger Bands
    bb = ta.bbands(close, length=params.bb_period, std=params.bb_std)
    if bb is not None:
        result["bb_lower"] = bb.iloc[:, 0]
        result["bb_mid"] = bb.iloc[:, 1]
        result["bb_upper"] = bb.iloc[:, 2]
        bb_width = (result["bb_upper"] - result["bb_lower"]) / result["bb_mid"]
        result["bb_width"] = bb_width
        result["bb_pct_b"] = (close - result["bb_lower"]) / (result["bb_upper"] - result["bb_lower"])

    # EMAs
    ema_short = ta.ema(close, length=params.ema_short)
    ema_long = ta.ema(close, length=params.ema_long)
    result["ema_short"] = ema_short
    result["ema_long"] = ema_long

    # ATR
    atr = ta.atr(high, low, close, length=params.atr_period)
    result["atr"] = atr
    result["atr_pct"] = (atr / close) * 100

    # OBV
    obv = ta.obv(close, volume)
    result["obv"] = obv
    result["obv_ema"] = ta.ema(obv, length=params.obv_ema) if obv is not None else None

    # VWAP (cumulative)
    typical_price = (high + low + close) / 3
    cumulative_tp_vol = (typical_price * volume).cumsum()
    cumulative_vol = volume.cumsum()
    result["vwap"] = cumulative_tp_vol / cumulative_vol

    return result


def compute_composite_score(row: pd.Series, weights: SignalWeights) -> float:
    """Compute composite signal score for a single day from indicator values.

    Returns a score in [-1, 1] range. Uses trend, momentum, volatility, volume
    components (no sentiment for backtesting). Weights are re-normalised to
    exclude the sentiment weight.
    """
    scores = {}

    # --- Trend score [-1, 1] ---
    trend = 0.0
    ema_s = row.get("ema_short")
    ema_l = row.get("ema_long")
    if pd.notna(ema_s) and pd.notna(ema_l) and ema_l != 0:
        # EMA alignment: +1 if short > long, -1 otherwise
        ema_diff = (ema_s - ema_l) / ema_l
        trend = np.clip(ema_diff * 20, -1, 1)  # scale so 5% diff = full signal
    close = row.get("close")
    vwap = row.get("vwap")
    if pd.notna(close) and pd.notna(vwap) and vwap != 0:
        vwap_diff = (close - vwap) / vwap
        vwap_score = np.clip(vwap_diff * 10, -1, 1)
        trend = 0.7 * trend + 0.3 * vwap_score
    scores["trend"] = trend

    # --- Momentum score [-1, 1] ---
    momentum = 0.0
    rsi = row.get("rsi")
    macd_hist = row.get("macd_hist")
    if pd.notna(rsi):
        # RSI: 50 is neutral, <30 oversold (bullish), >70 overbought (bearish)
        rsi_score = np.clip((50 - rsi) / 30, -1, 1)
        momentum += 0.5 * rsi_score
    if pd.notna(macd_hist):
        # Positive histogram = bullish momentum
        atr = row.get("atr")
        if pd.notna(atr) and atr > 0:
            macd_norm = np.clip(macd_hist / atr, -1, 1)
        else:
            macd_norm = np.clip(macd_hist * 10, -1, 1)
        momentum += 0.5 * macd_norm
    scores["momentum"] = np.clip(momentum, -1, 1)

    # --- Volatility score [-1, 1] ---
    volatility = 0.0
    bb_pct_b = row.get("bb_pct_b")
    bb_width = row.get("bb_width")
    if pd.notna(bb_pct_b):
        # %B < 0.2 = near lower band (bullish), > 0.8 = near upper (bearish)
        volatility = np.clip((0.5 - bb_pct_b) * 2, -1, 1)
    if pd.notna(bb_width) and bb_width < 0.04:
        # Bollinger squeeze — amplify signal slightly
        volatility *= 1.3
        volatility = np.clip(volatility, -1, 1)
    scores["volatility"] = volatility

    # --- Volume score [-1, 1] ---
    vol_score = 0.0
    obv = row.get("obv")
    obv_ema = row.get("obv_ema")
    if pd.notna(obv) and pd.notna(obv_ema) and obv_ema != 0:
        obv_diff = (obv - obv_ema) / abs(obv_ema) if obv_ema != 0 else 0
        vol_score = np.clip(obv_diff * 5, -1, 1)
    scores["volume"] = vol_score

    # Re-normalise weights excluding sentiment
    w_total = weights.trend + weights.momentum + weights.volatility + weights.volume
    if w_total == 0:
        return 0.0

    composite = (
        scores["trend"] * weights.trend
        + scores["momentum"] * weights.momentum
        + scores["volatility"] * weights.volatility
        + scores["volume"] * weights.volume
    ) / w_total

    return float(np.clip(composite, -1, 1))


def run_backtest(
    ticker: str,
    years: int = 5,
    initial_capital: float = 10000.0,
) -> dict:
    """Run backtest on a single ticker and return performance metrics."""
    params = IndicatorParams()
    risk = RiskParams()
    weights = SignalWeights()

    # Fetch extra history for 200-day EMA warmup
    fetch_years = max(years + 1, 2)
    period = f"{fetch_years}y"
    df = fetch_ohlcv(ticker, period=period, interval="1d")

    # Compute indicator series over the full history
    indicators = compute_indicator_series(df, params)

    # Determine the trading window — skip first 200 rows for warmup
    warmup = max(params.ema_long, 200)
    if len(indicators) <= warmup:
        return {
            "ticker": ticker,
            "error": f"Not enough data ({len(indicators)} bars, need > {warmup})",
        }

    trading_data = indicators.iloc[warmup:].copy()

    # Limit to requested number of years (approx 252 trading days per year)
    max_bars = years * 252
    if len(trading_data) > max_bars:
        trading_data = trading_data.iloc[-max_bars:]

    # --- Simulate trades ---
    capital = initial_capital
    position = None  # dict with entry_price, shares, entry_date, trailing_stop
    trades = []  # completed trades
    daily_equity = []

    for i, (date, row) in enumerate(trading_data.iterrows()):
        price = row["close"]
        atr = row["atr"] if pd.notna(row["atr"]) else 0
        score = compute_composite_score(row, weights)

        # Update trailing stop if in position
        if position is not None:
            new_stop = price - (atr * risk.atr_stop_multiplier) if atr > 0 else position["trailing_stop"]
            position["trailing_stop"] = max(position["trailing_stop"], new_stop)

            # Check sell conditions
            sell = False
            if score < -0.4:
                sell = True
            elif price <= position["trailing_stop"]:
                sell = True

            if sell:
                slippage_cost = price * risk.estimated_slippage
                sell_price = price - slippage_cost
                proceeds = sell_price * position["shares"]
                pnl = proceeds - position["cost_basis"]
                capital = proceeds
                trades.append({
                    "entry_date": str(position["entry_date"]),
                    "exit_date": str(date.date()) if hasattr(date, "date") else str(date),
                    "entry_price": position["entry_price"],
                    "exit_price": round(sell_price, 4),
                    "shares": position["shares"],
                    "pnl": round(pnl, 2),
                    "days_held": (date - position["entry_date_dt"]).days,
                })
                position = None

        # Check buy conditions (only if not in position)
        if position is None and score > 0.4:
            # Apply SDRT (stamp duty) and slippage on buy
            sdrt_cost = price * risk.sdrt_rate
            slippage_cost = price * risk.estimated_slippage
            buy_price = price + slippage_cost + sdrt_cost
            shares = capital / buy_price
            cost_basis = shares * buy_price
            trailing_stop = price - (atr * risk.atr_stop_multiplier) if atr > 0 else price * 0.95

            position = {
                "entry_price": round(buy_price, 4),
                "shares": shares,
                "cost_basis": cost_basis,
                "entry_date": str(date.date()) if hasattr(date, "date") else str(date),
                "entry_date_dt": date,
                "trailing_stop": trailing_stop,
            }
            capital = 0.0  # fully invested

        # Track daily equity
        if position is not None:
            equity = position["shares"] * price
        else:
            equity = capital
        daily_equity.append({"date": date, "equity": equity})

    # Close any open position at last price
    if position is not None:
        last_row = trading_data.iloc[-1]
        last_date = trading_data.index[-1]
        last_price = last_row["close"]
        slippage_cost = last_price * risk.estimated_slippage
        sell_price = last_price - slippage_cost
        proceeds = sell_price * position["shares"]
        pnl = proceeds - position["cost_basis"]
        capital = proceeds
        trades.append({
            "entry_date": position["entry_date"],
            "exit_date": str(last_date.date()) if hasattr(last_date, "date") else str(last_date),
            "entry_price": position["entry_price"],
            "exit_price": round(sell_price, 4),
            "shares": position["shares"],
            "pnl": round(pnl, 2),
            "days_held": (last_date - position["entry_date_dt"]).days,
        })
        position = None

    # --- Compute metrics ---
    equity_series = pd.Series(
        [d["equity"] for d in daily_equity],
        index=[d["date"] for d in daily_equity],
    )

    final_capital = capital if capital > 0 else equity_series.iloc[-1]
    total_return_pct = ((final_capital - initial_capital) / initial_capital) * 100

    # Benchmark: buy and hold (with SDRT + slippage on initial buy, slippage on final sell)
    first_price = trading_data.iloc[0]["close"]
    last_price = trading_data.iloc[-1]["close"]
    bh_buy_price = first_price * (1 + risk.sdrt_rate + risk.estimated_slippage)
    bh_sell_price = last_price * (1 - risk.estimated_slippage)
    benchmark_return_pct = ((bh_sell_price / bh_buy_price) - 1) * 100

    # Daily returns for Sharpe/Sortino
    daily_returns = equity_series.pct_change().dropna()

    # Sharpe ratio (annualised, rf=0)
    if len(daily_returns) > 1 and daily_returns.std() > 0:
        sharpe_ratio = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252)
    else:
        sharpe_ratio = 0.0

    # Sortino ratio (annualised, downside deviation only)
    downside = daily_returns[daily_returns < 0]
    if len(downside) > 1 and downside.std() > 0:
        sortino_ratio = (daily_returns.mean() / downside.std()) * np.sqrt(252)
    else:
        sortino_ratio = 0.0

    # Max drawdown
    cummax = equity_series.cummax()
    drawdown = (equity_series - cummax) / cummax
    max_drawdown_pct = float(drawdown.min()) * 100 if len(drawdown) > 0 else 0.0

    # Trade stats
    total_trades = len(trades)
    wins = [t for t in trades if t["pnl"] > 0]
    losses = [t for t in trades if t["pnl"] <= 0]
    win_rate_pct = (len(wins) / total_trades * 100) if total_trades > 0 else 0.0
    avg_trade_days = (sum(t["days_held"] for t in trades) / total_trades) if total_trades > 0 else 0.0

    gross_profit = sum(t["pnl"] for t in wins) if wins else 0.0
    gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 0.0
    profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf") if gross_profit > 0 else 0.0

    return {
        "ticker": ticker,
        "backtest_years": years,
        "initial_capital": initial_capital,
        "final_capital": round(final_capital, 2),
        "trading_days": len(trading_data),
        "data_start": str(trading_data.index[0].date()),
        "data_end": str(trading_data.index[-1].date()),
        "metrics": {
            "total_return_pct": round(total_return_pct, 2),
            "benchmark_return_pct": round(benchmark_return_pct, 2),
            "sharpe_ratio": round(sharpe_ratio, 4),
            "sortino_ratio": round(sortino_ratio, 4),
            "max_drawdown_pct": round(max_drawdown_pct, 2),
            "win_rate_pct": round(win_rate_pct, 2),
            "total_trades": total_trades,
            "avg_trade_days": round(avg_trade_days, 1),
            "profit_factor": round(profit_factor, 4) if profit_factor != float("inf") else "inf",
        },
        "trades": trades,
    }


def main():
    parser = argparse.ArgumentParser(description="Backtest composite signal strategy on LSE stocks")
    parser.add_argument("ticker", help="Yahoo Finance ticker (e.g., HSBA.L)")
    parser.add_argument("--years", type=int, default=5, help="Number of years to backtest (default: 5)")
    parser.add_argument("--initial-capital", type=float, default=10000, help="Initial capital in GBP (default: 10000)")
    args = parser.parse_args()

    ticker = args.ticker if "." in args.ticker else f"{args.ticker}.L"
    result = run_backtest(ticker, years=args.years, initial_capital=args.initial_capital)
    print(json.dumps(result, indent=2, default=str))


if __name__ == "__main__":
    main()


path: scripts/config.py
content: """Configuration for LSE Trading Agent."""

from dataclasses import dataclass, field


@dataclass(frozen=True)
class IndicatorParams:
    rsi_period: int = 14
    macd_fast: int = 12
    macd_slow: int = 26
    macd_signal: int = 9
    bb_period: int = 20
    bb_std: float = 2.0
    ema_short: int = 50
    ema_long: int = 200
    atr_period: int = 14
    vwap_period: int = 14
    obv_ema: int = 20


@dataclass(frozen=True)
class RiskParams:
    max_risk_per_trade: float = 0.02  # 2%
    max_position_pct: float = 0.05  # 5% of portfolio
    max_drawdown: float = 0.15  # 15% circuit breaker
    max_daily_loss: float = 0.03  # 3%
    max_sector_exposure: float = 0.25  # 25%
    max_open_positions: int = 10
    min_positions_above_10k: int = 5
    kelly_fraction: float = 0.5  # half-Kelly
    sdrt_rate: float = 0.005  # 0.5% stamp duty on UK equity buys
    estimated_slippage: float = 0.001  # 0.1% slippage estimate
    atr_stop_multiplier: float = 2.0


@dataclass(frozen=True)
class SignalWeights:
    trend: float = 0.25
    momentum: float = 0.25
    volatility: float = 0.15
    volume: float = 0.15
    sentiment: float = 0.20


# FTSE 350 sectors (GICS)
GICS_SECTORS = [
    "Energy",
    "Materials",
    "Industrials",
    "Consumer Discretionary",
    "Consumer Staples",
    "Health Care",
    "Financials",
    "Information Technology",
    "Communication Services",
    "Utilities",
    "Real Estate",
]

# LSE trading hours (UTC)
LSE_OPEN_HOUR = 8
LSE_CLOSE_HOUR = 16
LSE_CLOSE_MINUTE = 30


path: scripts/ftse350.py
content: """FTSE 350 ticker list with GICS sector mappings.

Hardcoded dictionary of ~100 of the most liquid FTSE 350 constituents,
mapped to their GICS sector classification. Tickers use Yahoo Finance
format with .L suffix.

Usage:
    uv run scripts/ftse350.py                          → JSON array of {ticker, sector}
    uv run scripts/ftse350.py --sector Financials      → filtered by sector
    uv run scripts/ftse350.py --list-sectors           → JSON array of sector names
"""

import argparse
import json
import sys

from config import GICS_SECTORS

# Mapping of Yahoo Finance ticker → GICS sector
# ~100 of the most liquid FTSE 350 names across all 11 GICS sectors
FTSE350: dict[str, str] = {
    # --- Energy ---
    "BP.L": "Energy",
    "SHEL.L": "Energy",
    "CNE.L": "Energy",
    "HTG.L": "Energy",
    "ENQ.L": "Energy",
    "PMO.L": "Energy",
    "TLW.L": "Energy",
    # --- Materials ---
    "AAL.L": "Materials",
    "ANTO.L": "Materials",
    "RIO.L": "Materials",
    "GLEN.L": "Materials",
    "FRES.L": "Materials",
    "EVR.L": "Materials",
    "MNDI.L": "Materials",
    "CRH.L": "Materials",
    "CRDA.L": "Materials",
    # --- Industrials ---
    "RR.L": "Industrials",
    "BA.L": "Industrials",
    "EXPN.L": "Industrials",
    "RS1.L": "Industrials",
    "BNZL.L": "Industrials",
    "SMDS.L": "Industrials",
    "RMV.L": "Industrials",
    "SMIN.L": "Industrials",
    "IMI.L": "Industrials",
    "WEIR.L": "Industrials",
    "SDR.L": "Industrials",
    "BWNG.L": "Industrials",
    "MGGT.L": "Industrials",
    # --- Consumer Discretionary ---
    "NXT.L": "Consumer Discretionary",
    "IHG.L": "Consumer Discretionary",
    "WTB.L": "Consumer Discretionary",
    "JD.L": "Consumer Discretionary",
    "FRAS.L": "Consumer Discretionary",
    "ENT.L": "Consumer Discretionary",
    "BDEV.L": "Consumer Discretionary",
    "TW.L": "Consumer Discretionary",
    "DGE.L": "Consumer Discretionary",
    "BWY.L": "Consumer Discretionary",
    "PSN.L": "Consumer Discretionary",
    "BRBY.L": "Consumer Discretionary",
    # --- Consumer Staples ---
    "ULVR.L": "Consumer Staples",
    "RKT.L": "Consumer Staples",
    "TSCO.L": "Consumer Staples",
    "SBRY.L": "Consumer Staples",
    "ABF.L": "Consumer Staples",
    "BME.L": "Consumer Staples",
    "GRG.L": "Consumer Staples",
    "HLMA.L": "Consumer Staples",
    "OCDO.L": "Consumer Staples",
    # --- Health Care ---
    "AZN.L": "Health Care",
    "GSK.L": "Health Care",
    "SN.L": "Health Care",
    "HIK.L": "Health Care",
    "DPLM.L": "Health Care",
    "SPX.L": "Health Care",
    "DARK.L": "Health Care",
    # --- Financials ---
    "HSBA.L": "Financials",
    "BARC.L": "Financials",
    "LLOY.L": "Financials",
    "NWG.L": "Financials",
    "STAN.L": "Financials",
    "AV.L": "Financials",
    "LGEN.L": "Financials",
    "PRU.L": "Financials",
    "ADM.L": "Financials",
    "III.L": "Financials",
    "HL.L": "Financials",
    "PHNX.L": "Financials",
    "INVP.L": "Financials",
    "MNG.L": "Financials",
    # --- Information Technology ---
    "SAGE.L": "Information Technology",
    "KNOS.L": "Information Technology",
    "AVV.L": "Information Technology",
    "FDM.L": "Information Technology",
    "SGE.L": "Information Technology",
    "AUTO.L": "Information Technology",
    # --- Communication Services ---
    "VOD.L": "Communication Services",
    "REL.L": "Communication Services",
    "WPP.L": "Communication Services",
    "ITV.L": "Communication Services",
    "INF.L": "Communication Services",
    # --- Utilities ---
    "NG.L": "Utilities",
    "SSE.L": "Utilities",
    "SVT.L": "Utilities",
    "UU.L": "Utilities",
    "PNN.L": "Utilities",
    "CNA.L": "Utilities",
    "DRX.L": "Utilities",
    # --- Real Estate ---
    "LAND.L": "Real Estate",
    "BLND.L": "Real Estate",
    "SGRO.L": "Real Estate",
    "UTG.L": "Real Estate",
    "HMSO.L": "Real Estate",
    "GPE.L": "Real Estate",
}


def get_tickers(sector: str | None = None) -> dict[str, str]:
    """Return FTSE 350 ticker-to-sector mapping, optionally filtered by sector.

    Args:
        sector: GICS sector name to filter by. If None, returns all tickers.

    Returns:
        Dict mapping ticker string to sector string.
    """
    if sector is None:
        return dict(FTSE350)
    return {t: s for t, s in FTSE350.items() if s == sector}


def get_sector(ticker: str) -> str | None:
    """Return the GICS sector for a given ticker, or None if not found.

    Args:
        ticker: Yahoo Finance ticker (e.g. "HSBA.L").

    Returns:
        Sector string or None.
    """
    return FTSE350.get(ticker)


def main():
    parser = argparse.ArgumentParser(description="FTSE 350 ticker list with GICS sector mappings")
    parser.add_argument("--sector", help="Filter by GICS sector name (e.g., Financials)")
    parser.add_argument("--list-sectors", action="store_true", help="List all GICS sector names")
    args = parser.parse_args()

    if args.list_sectors:
        print(json.dumps(GICS_SECTORS, indent=2))
        return

    tickers = get_tickers(sector=args.sector)

    if args.sector and not tickers:
        print(json.dumps({"error": f"No tickers found for sector: {args.sector}"}))
        sys.exit(1)

    result = [{"ticker": t, "sector": s} for t, s in sorted(tickers.items())]
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()


path: scripts/indicators.py
content: """Technical indicator calculations for LSE stocks.

Computes: RSI, MACD, Bollinger Bands, EMA 50/200, ATR, VWAP, OBV.
Outputs signal flags for golden cross, death cross, oversold, overbought, bollinger squeeze.

Usage:
    uv run scripts/indicators.py HSBA.L --period 1y
    uv run scripts/indicators.py VOD.L --period 6mo --interval 1d
"""

import argparse
import json
import sys
from datetime import datetime

import numpy as np
import pandas as pd
import pandas_ta as ta
import yfinance as yf

from config import IndicatorParams


def fetch_ohlcv(ticker: str, period: str = "1y", interval: str = "1d") -> pd.DataFrame:
    """Fetch OHLCV data from Yahoo Finance."""
    stock = yf.Ticker(ticker)
    df = stock.history(period=period, interval=interval)
    if df.empty:
        print(json.dumps({"error": f"No data found for {ticker}"}))
        sys.exit(1)
    return df


def compute_indicators(df: pd.DataFrame, params: IndicatorParams | None = None) -> dict:
    """Compute all technical indicators and return structured results."""
    if params is None:
        params = IndicatorParams()

    close = df["Close"]
    high = df["High"]
    low = df["Low"]
    volume = df["Volume"]

    # RSI
    rsi_series = ta.rsi(close, length=params.rsi_period)
    rsi = float(rsi_series.iloc[-1]) if rsi_series is not None else None

    # MACD
    macd_df = ta.macd(close, fast=params.macd_fast, slow=params.macd_slow, signal=params.macd_signal)
    macd_line = float(macd_df.iloc[-1, 0]) if macd_df is not None else None
    macd_signal = float(macd_df.iloc[-1, 2]) if macd_df is not None else None
    macd_hist = float(macd_df.iloc[-1, 1]) if macd_df is not None else None
    macd_hist_prev = float(macd_df.iloc[-2, 1]) if macd_df is not None and len(macd_df) > 1 else None

    # Bollinger Bands
    bb = ta.bbands(close, length=params.bb_period, std=params.bb_std)
    bb_upper = float(bb.iloc[-1, 0]) if bb is not None else None
    bb_mid = float(bb.iloc[-1, 1]) if bb is not None else None
    bb_lower = float(bb.iloc[-1, 2]) if bb is not None else None
    bb_width = (bb_upper - bb_lower) / bb_mid if bb_mid and bb_mid != 0 else None

    # Bollinger %B (where price sits within bands)
    current_price = float(close.iloc[-1])
    bb_pct_b = (current_price - bb_lower) / (bb_upper - bb_lower) if bb_upper and bb_lower and bb_upper != bb_lower else None

    # EMA 50 / 200
    ema_short = ta.ema(close, length=params.ema_short)
    ema_long = ta.ema(close, length=params.ema_long)
    ema_50 = float(ema_short.iloc[-1]) if ema_short is not None else None
    ema_200 = float(ema_long.iloc[-1]) if ema_long is not None else None

    # Golden cross / death cross detection (last 5 bars)
    golden_cross = False
    death_cross = False
    if ema_short is not None and ema_long is not None and len(ema_short) > 5:
        for i in range(-5, 0):
            prev_diff = ema_short.iloc[i - 1] - ema_long.iloc[i - 1]
            curr_diff = ema_short.iloc[i] - ema_long.iloc[i]
            if prev_diff <= 0 and curr_diff > 0:
                golden_cross = True
            if prev_diff >= 0 and curr_diff < 0:
                death_cross = True

    # ATR
    atr_series = ta.atr(high, low, close, length=params.atr_period)
    atr = float(atr_series.iloc[-1]) if atr_series is not None else None
    atr_pct = (atr / current_price * 100) if atr and current_price else None

    # VWAP (cumulative for the available data)
    typical_price = (high + low + close) / 3
    cumulative_tp_vol = (typical_price * volume).cumsum()
    cumulative_vol = volume.cumsum()
    vwap_series = cumulative_tp_vol / cumulative_vol
    vwap = float(vwap_series.iloc[-1]) if not vwap_series.empty else None

    # OBV
    obv_series = ta.obv(close, volume)
    obv = float(obv_series.iloc[-1]) if obv_series is not None else None
    obv_ema_series = ta.ema(obv_series, length=params.obv_ema) if obv_series is not None else None
    obv_ema = float(obv_ema_series.iloc[-1]) if obv_ema_series is not None else None
    obv_rising = obv > obv_ema if obv is not None and obv_ema is not None else None

    # Signal flags
    oversold = rsi is not None and rsi < 30
    overbought = rsi is not None and rsi > 70
    bollinger_squeeze = bb_width is not None and bb_width < 0.04
    above_vwap = current_price > vwap if vwap else None
    trend_up = ema_50 is not None and ema_200 is not None and ema_50 > ema_200
    macd_bullish = macd_hist is not None and macd_hist > 0
    macd_turning_up = (
        macd_hist is not None
        and macd_hist_prev is not None
        and macd_hist > macd_hist_prev
    )

    # Price change stats
    pct_1d = float((close.iloc[-1] / close.iloc[-2] - 1) * 100) if len(close) > 1 else None
    pct_5d = float((close.iloc[-1] / close.iloc[-6] - 1) * 100) if len(close) > 5 else None
    pct_20d = float((close.iloc[-1] / close.iloc[-21] - 1) * 100) if len(close) > 20 else None

    return {
        "ticker": df.index.name or "unknown",
        "price": round(current_price, 2),
        "date": str(df.index[-1].date()),
        "change_1d_pct": round(pct_1d, 2) if pct_1d else None,
        "change_5d_pct": round(pct_5d, 2) if pct_5d else None,
        "change_20d_pct": round(pct_20d, 2) if pct_20d else None,
        "indicators": {
            "rsi": round(rsi, 2) if rsi else None,
            "macd": {
                "line": round(macd_line, 4) if macd_line else None,
                "signal": round(macd_signal, 4) if macd_signal else None,
                "histogram": round(macd_hist, 4) if macd_hist else None,
            },
            "bollinger": {
                "upper": round(bb_upper, 2) if bb_upper else None,
                "mid": round(bb_mid, 2) if bb_mid else None,
                "lower": round(bb_lower, 2) if bb_lower else None,
                "width": round(bb_width, 4) if bb_width else None,
                "pct_b": round(bb_pct_b, 4) if bb_pct_b else None,
            },
            "ema": {
                "ema_50": round(ema_50, 2) if ema_50 else None,
                "ema_200": round(ema_200, 2) if ema_200 else None,
            },
            "atr": round(atr, 4) if atr else None,
            "atr_pct": round(atr_pct, 2) if atr_pct else None,
            "vwap": round(vwap, 2) if vwap else None,
            "obv": round(obv, 0) if obv else None,
        },
        "signals": {
            "trend_up": trend_up,
            "golden_cross": golden_cross,
            "death_cross": death_cross,
            "oversold": oversold,
            "overbought": overbought,
            "macd_bullish": macd_bullish,
            "macd_turning_up": macd_turning_up,
            "bollinger_squeeze": bollinger_squeeze,
            "above_vwap": above_vwap,
            "obv_rising": obv_rising,
        },
    }


def main():
    parser = argparse.ArgumentParser(description="Compute technical indicators for an LSE stock")
    parser.add_argument("ticker", help="Yahoo Finance ticker (e.g., HSBA.L)")
    parser.add_argument("--period", default="1y", help="Data period (1mo, 3mo, 6mo, 1y, 2y, 5y)")
    parser.add_argument("--interval", default="1d", help="Data interval (1d, 1wk, 1mo)")
    args = parser.parse_args()

    ticker = args.ticker if "." in args.ticker else f"{args.ticker}.L"
    df = fetch_ohlcv(ticker, period=args.period, interval=args.interval)
    result = compute_indicators(df)
    result["ticker"] = ticker
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()


path: scripts/portfolio.py
content: """Portfolio tracker with CRUD operations and live price fetching.

Manages a local JSON portfolio file with positions, cash tracking,
and live P&L calculations via yfinance.

Usage:
    uv run scripts/portfolio.py --init 50000
    uv run scripts/portfolio.py --show
    uv run scripts/portfolio.py --add HSBA.L 100 678.5
    uv run scripts/portfolio.py --remove HSBA.L
    uv run scripts/portfolio.py --summary
    uv run scripts/portfolio.py --show --file path/to/portfolio.json
"""

import argparse
import json
import sys
from datetime import date
from pathlib import Path

import yfinance as yf

from config import RiskParams
from ftse350 import get_sector

RISK = RiskParams()
DEFAULT_PORTFOLIO = Path(__file__).resolve().parent.parent / "data" / "portfolio.json"


def _load(path: Path) -> dict:
    """Load portfolio from JSON file."""
    if not path.exists():
        print(json.dumps({"error": f"Portfolio file not found: {path}"}))
        sys.exit(1)
    with open(path) as f:
        return json.load(f)


def _save(data: dict, path: Path) -> None:
    """Save portfolio to JSON file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w") as f:
        json.dump(data, f, indent=2)


def _normalize_ticker(ticker: str) -> str:
    """Append .L suffix if missing."""
    return ticker if "." in ticker else f"{ticker}.L"


def _fetch_price(ticker: str) -> float | None:
    """Fetch the latest price for a ticker via yfinance."""
    try:
        stock = yf.Ticker(ticker)
        hist = stock.history(period="5d")
        if hist.empty:
            return None
        return float(hist["Close"].iloc[-1])
    except Exception:
        return None


def init_portfolio(capital: float, path: Path) -> None:
    """Create a new portfolio with the given starting capital."""
    data = {
        "created": str(date.today()),
        "initial_capital": capital,
        "cash": capital,
        "positions": [],
    }
    _save(data, path)
    print(json.dumps(data, indent=2))


def show_portfolio(path: Path) -> None:
    """Show all positions with live prices and P&L."""
    data = _load(path)
    positions = data["positions"]
    cash = data["cash"]

    enriched = []
    total_invested = 0.0
    total_market_value = 0.0
    total_pnl = 0.0

    for pos in positions:
        ticker = pos["ticker"]
        shares = pos["shares"]
        entry_price = pos["entry_price"]

        current_price = _fetch_price(ticker)
        if current_price is None:
            current_price = entry_price  # fallback if fetch fails

        cost_basis = shares * entry_price
        market_value = shares * current_price
        pnl = market_value - cost_basis
        pnl_pct = (pnl / cost_basis * 100) if cost_basis != 0 else 0.0

        total_invested += cost_basis
        total_market_value += market_value
        total_pnl += pnl

        enriched.append({
            "ticker": ticker,
            "shares": shares,
            "entry_price": entry_price,
            "current_price": round(current_price, 2),
            "entry_date": pos["entry_date"],
            "sector": pos["sector"],
            "market_value": round(market_value, 2),
            "pnl": round(pnl, 2),
            "pnl_pct": round(pnl_pct, 2),
        })

    total_value = cash + total_market_value
    unrealized_pnl_pct = (total_pnl / total_invested * 100) if total_invested != 0 else 0.0

    # Sector exposure as % of total portfolio value
    sector_values: dict[str, float] = {}
    for p in enriched:
        sector_values[p["sector"]] = sector_values.get(p["sector"], 0.0) + p["market_value"]
    sector_exposure = {
        s: round(v / total_value * 100, 2) if total_value else 0.0
        for s, v in sorted(sector_values.items())
    }

    result = {
        "total_value": round(total_value, 2),
        "cash": round(cash, 2),
        "invested": round(total_market_value, 2),
        "unrealized_pnl": round(total_pnl, 2),
        "unrealized_pnl_pct": round(unrealized_pnl_pct, 2),
        "positions": enriched,
        "sector_exposure": sector_exposure,
        "position_count": len(enriched),
    }
    print(json.dumps(result, indent=2))


def add_position(ticker: str, shares: int, price: float, path: Path) -> None:
    """Add a position to the portfolio."""
    ticker = _normalize_ticker(ticker)
    data = _load(path)

    sector = get_sector(ticker)
    if sector is None:
        print(json.dumps({"error": f"Ticker {ticker} not found in FTSE 350 list"}))
        sys.exit(1)

    # Cost includes SDRT (stamp duty)
    total_cost = shares * price * (1 + RISK.sdrt_rate)

    if total_cost > data["cash"]:
        print(json.dumps({
            "error": "Insufficient cash",
            "required": round(total_cost, 2),
            "available": round(data["cash"], 2),
        }))
        sys.exit(1)

    data["cash"] -= total_cost
    data["positions"].append({
        "ticker": ticker,
        "shares": shares,
        "entry_price": price,
        "entry_date": str(date.today()),
        "sector": sector,
    })

    _save(data, path)
    print(json.dumps({
        "action": "added",
        "ticker": ticker,
        "shares": shares,
        "entry_price": price,
        "cost_with_sdrt": round(total_cost, 2),
        "remaining_cash": round(data["cash"], 2),
        "sector": sector,
    }, indent=2))


def remove_position(ticker: str, path: Path) -> None:
    """Remove a position (sell all shares) from the portfolio."""
    ticker = _normalize_ticker(ticker)
    data = _load(path)

    # Find the position
    pos_idx = None
    for i, pos in enumerate(data["positions"]):
        if pos["ticker"] == ticker:
            pos_idx = i
            break

    if pos_idx is None:
        print(json.dumps({"error": f"Position {ticker} not found in portfolio"}))
        sys.exit(1)

    pos = data["positions"][pos_idx]
    current_price = _fetch_price(ticker)
    if current_price is None:
        print(json.dumps({"error": f"Could not fetch price for {ticker}"}))
        sys.exit(1)

    # Proceeds after slippage deduction
    proceeds = pos["shares"] * current_price * (1 - RISK.estimated_slippage)
    cost_basis = pos["shares"] * pos["entry_price"]
    realized_pnl = proceeds - cost_basis

    data["cash"] += proceeds
    data["positions"].pop(pos_idx)

    _save(data, path)
    print(json.dumps({
        "action": "removed",
        "ticker": ticker,
        "shares": pos["shares"],
        "sell_price": round(current_price, 2),
        "proceeds_after_slippage": round(proceeds, 2),
        "realized_pnl": round(realized_pnl, 2),
        "cash": round(data["cash"], 2),
    }, indent=2))


def summary(path: Path) -> None:
    """Print a quick one-line summary of the portfolio."""
    data = _load(path)
    positions = data["positions"]
    cash = data["cash"]

    total_market_value = 0.0
    total_cost = 0.0
    for pos in positions:
        current_price = _fetch_price(pos["ticker"])
        if current_price is None:
            current_price = pos["entry_price"]
        total_market_value += pos["shares"] * current_price
        total_cost += pos["shares"] * pos["entry_price"]

    total_value = cash + total_market_value
    pnl = total_market_value - total_cost
    pnl_pct = (pnl / total_cost * 100) if total_cost != 0 else 0.0

    print(
        f"Total: {total_value:,.2f} | "
        f"Cash: {cash:,.2f} | "
        f"Invested: {total_market_value:,.2f} | "
        f"P&L: {pnl:+,.2f} ({pnl_pct:+.2f}%) | "
        f"Positions: {len(positions)}"
    )


def main():
    parser = argparse.ArgumentParser(description="Portfolio tracker for LSE Trading Agent")
    parser.add_argument("--file", type=Path, default=DEFAULT_PORTFOLIO, help="Portfolio JSON file path")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--init", type=float, metavar="CAPITAL", help="Create new portfolio with starting capital")
    group.add_argument("--show", action="store_true", help="Show positions with live prices and P&L")
    group.add_argument("--add", nargs=3, metavar=("TICKER", "SHARES", "PRICE"), help="Add a position")
    group.add_argument("--remove", metavar="TICKER", help="Remove a position (sell all)")
    group.add_argument("--summary", action="store_true", help="Quick one-line summary")

    args = parser.parse_args()
    path = args.file

    if args.init is not None:
        init_portfolio(args.init, path)
    elif args.show:
        show_portfolio(path)
    elif args.add:
        ticker, shares_str, price_str = args.add
        add_position(ticker, int(shares_str), float(price_str), path)
    elif args.remove:
        remove_position(args.remove, path)
    elif args.summary:
        summary(path)


if __name__ == "__main__":
    main()


path: scripts/risk.py
content: """Risk validator and position sizer for LSE trading.

Validates proposed trades against risk rules and computes position sizing
using half-Kelly criterion and ATR-based stop losses.

Usage:
    uv run scripts/risk.py --action BUY --ticker HSBA.L --price 678.5 --portfolio-value 50000
    uv run scripts/risk.py --check-exposure --portfolio-file data/portfolio.json
"""

import argparse
import json
import math
import sys
from pathlib import Path

from config import RiskParams
from indicators import fetch_ohlcv, compute_indicators


def load_portfolio(portfolio_path: str) -> dict | None:
    """Load portfolio JSON file, returning None if it doesn't exist."""
    path = Path(portfolio_path)
    if not path.exists():
        return None
    with open(path) as f:
        return json.load(f)


def compute_kelly_size(
    portfolio_value: float,
    risk_params: RiskParams,
    win_rate: float = 0.55,
    payoff_ratio: float = 2.0,
) -> float:
    """Compute half-Kelly position size as fraction of portfolio.

    Returns the recommended position value (not shares).
    """
    kelly_raw = win_rate - (1 - win_rate) / payoff_ratio
    kelly_raw = max(kelly_raw, 0.0)  # never negative
    kelly_adj = risk_params.kelly_fraction * kelly_raw
    position_value = kelly_adj * portfolio_value
    # Cap at max_position_pct
    max_value = risk_params.max_position_pct * portfolio_value
    return min(position_value, max_value)


def compute_stop_loss(price: float, atr: float, risk_params: RiskParams, action: str) -> float:
    """Compute ATR-based stop loss price."""
    if action == "BUY":
        return price - (atr * risk_params.atr_stop_multiplier)
    else:  # SELL / short
        return price + (atr * risk_params.atr_stop_multiplier)


def get_atr_for_ticker(ticker: str) -> float | None:
    """Fetch current ATR for a ticker. Returns None on failure."""
    try:
        df = fetch_ohlcv(ticker, period="3mo", interval="1d")
        result = compute_indicators(df)
        return result["indicators"]["atr"]
    except Exception:
        return None


def validate_trade(
    action: str,
    ticker: str,
    price: float,
    portfolio_value: float,
    portfolio: dict | None = None,
    risk_params: RiskParams | None = None,
    win_rate: float = 0.55,
    payoff_ratio: float = 2.0,
) -> dict:
    """Validate a proposed trade against risk rules and compute sizing."""
    if risk_params is None:
        risk_params = RiskParams()

    checks = {}

    # Compute Kelly position size
    position_value = compute_kelly_size(portfolio_value, risk_params, win_rate, payoff_ratio)
    position_pct = position_value / portfolio_value * 100
    max_pct = risk_params.max_position_pct * 100

    checks["position_size"] = {
        "pass": position_pct <= max_pct,
        "detail": f"{position_pct:.1f}% < {max_pct:.1f}% max"
        if position_pct <= max_pct
        else f"{position_pct:.1f}% exceeds {max_pct:.1f}% max",
    }

    # Fetch ATR for stop loss
    atr = get_atr_for_ticker(ticker)
    if atr is not None and atr > 0:
        stop_loss_price = compute_stop_loss(price, atr, risk_params, action)
        risk_per_share = abs(price - stop_loss_price)
        shares = math.floor(position_value / price)
        risk_amount = risk_per_share * shares
        risk_pct = risk_amount / portfolio_value * 100
        max_risk_pct = risk_params.max_risk_per_trade * 100

        # If risk exceeds limit, reduce shares
        if risk_pct > max_risk_pct:
            max_risk_amount = risk_params.max_risk_per_trade * portfolio_value
            shares = math.floor(max_risk_amount / risk_per_share)
            position_value = shares * price
            risk_amount = risk_per_share * shares
            risk_pct = risk_amount / portfolio_value * 100

        checks["risk_per_trade"] = {
            "pass": risk_pct <= max_risk_pct,
            "detail": f"{risk_pct:.1f}% < {max_risk_pct:.1f}% max"
            if risk_pct <= max_risk_pct
            else f"{risk_pct:.1f}% exceeds {max_risk_pct:.1f}% max",
        }
    else:
        stop_loss_price = None
        shares = math.floor(position_value / price)
        checks["risk_per_trade"] = {
            "pass": True,
            "detail": "ATR unavailable — skipped risk-per-trade check",
        }

    # Portfolio-dependent checks
    if portfolio is not None:
        positions = portfolio.get("positions", [])

        # Sector exposure
        sector_values: dict[str, float] = {}
        total_value = portfolio.get("total_value", portfolio_value)
        for pos in positions:
            sector = pos.get("sector", "Unknown")
            val = pos.get("market_value", 0)
            sector_values[sector] = sector_values.get(sector, 0) + val

        # Find the sector for the new trade (if provided in portfolio metadata)
        new_sector = portfolio.get("ticker_sectors", {}).get(ticker, "Unknown")
        sector_values[new_sector] = sector_values.get(new_sector, 0) + position_value
        max_sector_pct = risk_params.max_sector_exposure * 100

        sector_exposure_pct = (
            sector_values[new_sector] / total_value * 100 if total_value > 0 else 0
        )
        checks["sector_exposure"] = {
            "pass": sector_exposure_pct <= max_sector_pct,
            "detail": f"{new_sector} at {sector_exposure_pct:.1f}% < {max_sector_pct:.1f}% max"
            if sector_exposure_pct <= max_sector_pct
            else f"{new_sector} at {sector_exposure_pct:.1f}% exceeds {max_sector_pct:.1f}% max",
        }

        # Open positions count
        position_count = len(positions)
        max_positions = risk_params.max_open_positions
        checks["open_positions"] = {
            "pass": position_count < max_positions,
            "detail": f"{position_count} < {max_positions} max"
            if position_count < max_positions
            else f"{position_count} at or exceeds {max_positions} max",
        }

        # Drawdown check
        peak_value = portfolio.get("peak_value", total_value)
        drawdown_pct = (
            (peak_value - total_value) / peak_value * 100 if peak_value > 0 else 0
        )
        max_dd_pct = risk_params.max_drawdown * 100
        checks["drawdown"] = {
            "pass": drawdown_pct <= max_dd_pct,
            "detail": f"{drawdown_pct:.1f}% < {max_dd_pct:.1f}% circuit breaker"
            if drawdown_pct <= max_dd_pct
            else f"{drawdown_pct:.1f}% exceeds {max_dd_pct:.1f}% circuit breaker",
        }
    else:
        checks["sector_exposure"] = {
            "pass": True,
            "detail": "No portfolio file — skipped",
        }
        checks["open_positions"] = {
            "pass": True,
            "detail": "No portfolio file — skipped",
        }
        checks["drawdown"] = {
            "pass": True,
            "detail": "No portfolio file — skipped",
        }

    all_pass = all(c["pass"] for c in checks.values())

    # Compute total cost with SDRT (stamp duty only on buys)
    actual_value = shares * price
    sdrt = actual_value * risk_params.sdrt_rate if action == "BUY" else 0
    total_cost = actual_value + sdrt

    kelly_raw = win_rate - (1 - win_rate) / payoff_ratio
    kelly_used = risk_params.kelly_fraction * max(kelly_raw, 0.0)

    result = {
        "action": action,
        "ticker": ticker,
        "price": price,
        "checks": checks,
        "all_pass": all_pass,
        "recommended_shares": shares,
        "recommended_value": round(actual_value, 2),
        "stop_loss_price": round(stop_loss_price, 2) if stop_loss_price is not None else None,
        "stop_loss_method": f"ATR x {risk_params.atr_stop_multiplier}",
        "total_cost_with_sdrt": round(total_cost, 2),
        "kelly_fraction_used": round(kelly_used, 4),
    }

    return result


def check_exposure(portfolio_path: str, risk_params: RiskParams | None = None) -> dict:
    """Check portfolio exposure against risk limits."""
    if risk_params is None:
        risk_params = RiskParams()

    portfolio = load_portfolio(portfolio_path)
    if portfolio is None:
        return {"error": f"Portfolio file not found: {portfolio_path}"}

    positions = portfolio.get("positions", [])
    total_value = portfolio.get("total_value", 0)
    peak_value = portfolio.get("peak_value", total_value)

    # Sector exposure breakdown
    sector_values: dict[str, float] = {}
    for pos in positions:
        sector = pos.get("sector", "Unknown")
        val = pos.get("market_value", 0)
        sector_values[sector] = sector_values.get(sector, 0) + val

    sector_exposure = {
        sector: round(val / total_value * 100, 1) if total_value > 0 else 0
        for sector, val in sector_values.items()
    }

    max_sector_pct = risk_params.max_sector_exposure * 100
    sectors_over_limit = [
        sector for sector, pct in sector_exposure.items() if pct > max_sector_pct
    ]

    drawdown_pct = (peak_value - total_value) / peak_value * 100 if peak_value > 0 else 0

    return {
        "sector_exposure": sector_exposure,
        "sectors_over_limit": sectors_over_limit,
        "position_count": len(positions),
        "max_positions": risk_params.max_open_positions,
        "estimated_drawdown_pct": round(drawdown_pct, 1),
        "circuit_breaker_triggered": drawdown_pct > risk_params.max_drawdown * 100,
    }


def main():
    parser = argparse.ArgumentParser(description="Risk validator and position sizer for LSE trading")

    # Mode 1: Trade validation
    parser.add_argument("--action", choices=["BUY", "SELL"], help="Trade action")
    parser.add_argument("--ticker", help="Yahoo Finance ticker (e.g., HSBA.L)")
    parser.add_argument("--price", type=float, help="Entry price")
    parser.add_argument("--portfolio-value", type=float, help="Total portfolio value")
    parser.add_argument("--win-rate", type=float, default=0.55, help="Estimated win rate")
    parser.add_argument("--payoff-ratio", type=float, default=2.0, help="Reward-to-risk ratio")

    # Mode 2: Exposure check
    parser.add_argument("--check-exposure", action="store_true", help="Check portfolio exposure")

    # Shared
    parser.add_argument("--portfolio-file", help="Path to portfolio JSON file")

    args = parser.parse_args()

    if args.check_exposure:
        if not args.portfolio_file:
            print(json.dumps({"error": "--portfolio-file required with --check-exposure"}))
            sys.exit(1)
        result = check_exposure(args.portfolio_file)
        print(json.dumps(result, indent=2))
    elif args.action and args.ticker and args.price and args.portfolio_value:
        ticker = args.ticker if "." in args.ticker else f"{args.ticker}.L"
        portfolio = load_portfolio(args.portfolio_file) if args.portfolio_file else None
        result = validate_trade(
            action=args.action,
            ticker=ticker,
            price=args.price,
            portfolio_value=args.portfolio_value,
            portfolio=portfolio,
            win_rate=args.win_rate,
            payoff_ratio=args.payoff_ratio,
        )
        print(json.dumps(result, indent=2))
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/screener.py
content: """FTSE 350 screener — scans tickers and ranks by composite technical score.

Fetches OHLCV data for FTSE 350 stocks, computes technical indicators,
and produces a composite score (-1.0 to +1.0) weighted across trend,
momentum, volatility, volume, and sentiment dimensions.

Usage:
    uv run scripts/screener.py                              → top 20 across all sectors
    uv run scripts/screener.py --sector Financials --top 5  → top 5 financials
    uv run scripts/screener.py --min-score 0.3              → only scores >= 0.3
"""

import argparse
import json
import sys
from typing import Any

from config import IndicatorParams, SignalWeights
from ftse350 import get_tickers
from indicators import compute_indicators, fetch_ohlcv


def _log(msg: str) -> None:
    """Print progress message to stderr."""
    print(msg, file=sys.stderr, flush=True)


def score_trend(signals: dict) -> float:
    """Score the trend component (-1.0 to +1.0).

    +1 if golden_cross or trend_up, -1 if death_cross or not trend_up, 0 otherwise.
    """
    if signals.get("golden_cross"):
        return 1.0
    if signals.get("death_cross"):
        return -1.0
    if signals.get("trend_up"):
        return 1.0
    if signals.get("trend_up") is False:
        return -1.0
    return 0.0


def score_momentum(indicators: dict, signals: dict) -> float:
    """Score the momentum component (-1.0 to +1.0).

    Averages an RSI-based score and a MACD histogram sign score.
    """
    # RSI score
    rsi = indicators.get("rsi")
    if rsi is None:
        rsi_score = 0.0
    elif rsi < 30:
        rsi_score = 1.0  # oversold bounce
    elif rsi < 50:
        rsi_score = 0.5  # rising
    elif rsi <= 70:
        rsi_score = 0.0  # neutral
    else:
        rsi_score = -1.0  # overbought

    # MACD histogram sign
    macd_data = indicators.get("macd", {})
    macd_hist = macd_data.get("histogram")
    if macd_hist is None:
        macd_score = 0.0
    elif macd_hist > 0:
        macd_score = 1.0
    elif macd_hist < 0:
        macd_score = -1.0
    else:
        macd_score = 0.0

    return (rsi_score + macd_score) / 2.0


def score_volatility(indicators: dict, signals: dict) -> float:
    """Score the volatility component (-1.0 to +1.0).

    Uses Bollinger %B. Buy the dip if trend_up and pct_b < 0.3,
    sell the rip if not trend_up and pct_b > 0.7, otherwise interpolate.
    """
    bb = indicators.get("bollinger", {})
    pct_b = bb.get("pct_b")
    trend_up = signals.get("trend_up", False)

    if pct_b is None:
        return 0.0

    if trend_up and pct_b < 0.3:
        return 1.0  # buy the dip
    if not trend_up and pct_b > 0.7:
        return -1.0  # sell the rip

    # Interpolate: map pct_b 0→+1, 0.5→0, 1→-1
    return 1.0 - 2.0 * max(0.0, min(1.0, pct_b))


def score_volume(signals: dict) -> float:
    """Score the volume component (-1.0 to +1.0).

    +1 if obv_rising AND above_vwap, -1 if both false, 0 if mixed.
    """
    obv_rising = signals.get("obv_rising")
    above_vwap = signals.get("above_vwap")

    if obv_rising is None or above_vwap is None:
        return 0.0

    if obv_rising and above_vwap:
        return 1.0
    if not obv_rising and not above_vwap:
        return -1.0
    return 0.0


def composite_score(result: dict, weights: SignalWeights | None = None) -> dict[str, float]:
    """Calculate weighted composite score from indicator results.

    Returns dict with individual component scores and the final composite.
    """
    if weights is None:
        weights = SignalWeights()

    signals = result.get("signals", {})
    indicators = result.get("indicators", {})

    trend = score_trend(signals)
    momentum = score_momentum(indicators, signals)
    volatility = score_volatility(indicators, signals)
    volume = score_volume(signals)
    sentiment = 0.0  # placeholder — filled by /lse-analyze

    score = (
        weights.trend * trend
        + weights.momentum * momentum
        + weights.volatility * volatility
        + weights.volume * volume
        + weights.sentiment * sentiment
    )

    return {
        "trend": round(trend, 4),
        "momentum": round(momentum, 4),
        "volatility": round(volatility, 4),
        "volume": round(volume, 4),
        "sentiment": round(sentiment, 4),
        "composite": round(score, 4),
    }


def scan_ticker(ticker: str, sector: str, params: IndicatorParams) -> dict[str, Any] | None:
    """Fetch data and compute score for a single ticker. Returns None on failure."""
    try:
        df = fetch_ohlcv(ticker, period="1y", interval="1d")
        result = compute_indicators(df, params)
        result["ticker"] = ticker

        scores = composite_score(result)

        return {
            "ticker": ticker,
            "sector": sector,
            "price": result.get("price"),
            "score": scores["composite"],
            "signals": {
                "trend": scores["trend"],
                "momentum": scores["momentum"],
                "volatility": scores["volatility"],
                "volume": scores["volume"],
            },
            "rsi": result.get("indicators", {}).get("rsi"),
            "macd_hist": result.get("indicators", {}).get("macd", {}).get("histogram"),
            "change_1d_pct": result.get("change_1d_pct"),
        }
    except SystemExit:
        # fetch_ohlcv calls sys.exit on empty data — catch it
        _log(f"  [skip] {ticker}: no data available")
        return None
    except Exception as e:
        _log(f"  [skip] {ticker}: {e}")
        return None


def run_screener(
    sector: str | None = None,
    top: int = 20,
    min_score: float | None = None,
) -> list[dict[str, Any]]:
    """Scan FTSE 350 tickers and return ranked results."""
    tickers = get_tickers(sector=sector)
    params = IndicatorParams()
    total = len(tickers)

    _log(f"Scanning {total} tickers" + (f" in {sector}" if sector else "") + "...")

    results: list[dict[str, Any]] = []
    for i, (ticker, sec) in enumerate(tickers.items(), 1):
        _log(f"  [{i}/{total}] {ticker}")
        entry = scan_ticker(ticker, sec, params)
        if entry is not None:
            results.append(entry)

    # Filter by min_score
    if min_score is not None:
        results = [r for r in results if r["score"] >= min_score]

    # Sort by score descending
    results.sort(key=lambda r: r["score"], reverse=True)

    # Limit to top N
    results = results[:top]

    _log(f"Done. {len(results)} stocks returned.")
    return results


def main():
    parser = argparse.ArgumentParser(description="FTSE 350 screener — rank stocks by composite technical score")
    parser.add_argument("--top", type=int, default=20, help="Number of top stocks to return (default: 20)")
    parser.add_argument("--sector", type=str, default=None, help="Filter by GICS sector (e.g., Financials)")
    parser.add_argument("--min-score", type=float, default=None, help="Minimum composite score threshold")
    args = parser.parse_args()

    results = run_screener(sector=args.sector, top=args.top, min_score=args.min_score)
    print(json.dumps(results, indent=2))


if __name__ == "__main__":
    main()


path: scripts/sentiment.py
content: """Fetch recent news headlines for an LSE stock from Yahoo Finance.

Outputs JSON for the OpenClaw agent (LLM) to analyze sentiment.
No ML dependencies — just raw headlines for the LLM to interpret.

Usage:
    uv run scripts/sentiment.py HSBA.L
    uv run scripts/sentiment.py HSBA.L --max-headlines 10
    uv run scripts/sentiment.py VOD
"""

import argparse
import json
import sys
from datetime import datetime, timezone

import yfinance as yf


def fetch_headlines(ticker: str, max_headlines: int = 50) -> dict:
    """Fetch recent news headlines from yfinance for a given ticker."""
    try:
        stock = yf.Ticker(ticker)
        raw_news = stock.news
    except Exception as e:
        return {
            "ticker": ticker,
            "headline_count": 0,
            "headlines": [],
            "error": f"Failed to fetch news: {e}",
        }

    if not raw_news or not isinstance(raw_news, list):
        return {
            "ticker": ticker,
            "headline_count": 0,
            "headlines": [],
        }

    headlines = []
    for item in raw_news[:max_headlines]:
        try:
            # yfinance >= 0.2.31 nests data under a "content" key
            content = item.get("content", item) if isinstance(item, dict) else {}

            title = (
                content.get("title")
                or content.get("headline")
                or item.get("title")
                or ""
            )
            publisher = (
                (content.get("provider") or {}).get("displayName")
                or content.get("publisher")
                or item.get("publisher")
                or ""
            )

            # Link: prefer canonicalUrl, fall back to clickThroughUrl or flat keys
            canon = content.get("canonicalUrl") or {}
            click = content.get("clickThroughUrl") or {}
            link = (
                canon.get("url")
                or click.get("url")
                or content.get("link")
                or item.get("link")
                or ""
            )

            # Publish time: ISO string or unix timestamp
            pub_raw = (
                content.get("pubDate")
                or content.get("displayTime")
                or item.get("providerPublishTime")
                or item.get("published")
            )
            if isinstance(pub_raw, (int, float)):
                published = datetime.fromtimestamp(pub_raw, tz=timezone.utc).isoformat()
            elif isinstance(pub_raw, str):
                published = pub_raw
            else:
                published = None

            if not title:
                continue

            headlines.append({
                "title": title,
                "publisher": publisher,
                "link": link,
                "published": published,
            })
        except Exception:
            # Skip malformed items without crashing
            continue

    return {
        "ticker": ticker,
        "headline_count": len(headlines),
        "headlines": headlines,
    }


def main():
    parser = argparse.ArgumentParser(description="Fetch recent news headlines for an LSE stock")
    parser.add_argument("ticker", help="Yahoo Finance ticker (e.g., HSBA.L)")
    parser.add_argument("--max-headlines", type=int, default=50, help="Maximum number of headlines to return")
    args = parser.parse_args()

    ticker = args.ticker if "." in args.ticker else f"{args.ticker}.L"
    result = fetch_headlines(ticker, max_headlines=args.max_headlines)
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)