ChatGPTを使用したアプリ開発記【Apple株の自動売買バックテスト】

## バックテスト結果
```json
{
  "average_loss": 2.91,
  "average_profit_per_trade": 0.99,
  "average_win": 4.6,
  "last_close": 195.34,
  "losses": 12,
  "risk_reward_ratio": 1.58,
  "total_profit": 24.86,
  "trades": 25,
  "win_rate_percent": 52.0,
  "wins": 13
}
```
1. 勝率（52.0%）
	•	50%を超えているので、勝ち越しです。単純に勝ち負けで見るとトレードの半分以上が利益になっています。

2. リスクリワード比（1.58）
	•	リスクリワード比とは「平均利益 ÷ 平均損失」のことで、1より大きいのは理想的です。
	•	1.58は「損失1ドルあたり1.58ドルの利益が得られている」ことを意味します。
	•	この数値が1以上だと、たとえ勝率が50%を少し下回っても利益が出る戦略になります。

3. 平均利益（4.6）と平均損失（2.91）
	•	勝ちトレード1回あたりの利益は平均4.6ドル。
	•	負けトレード1回あたりの損失は平均2.91ドル。
	•	利益の幅が損失より大きいのも良い点です。

4. 総利益（24.86ドル）
	•	実際の期間内のトータル利益もプラスであり、トレード回数25回中13回勝ち越し。


# ロジックコード
```app.py
#!/Users/kerumatomomitsu/keruma-sns-app/venv/bin/python3
# -*- coding: utf-8 -*-

import os
from flask import Flask, request, jsonify
from flask_cors import CORS
import requests
from dotenv import load_dotenv
import pandas as pd
from ta.momentum import RSIIndicator
from ta.trend import SMAIndicator, MACD

load_dotenv()

app = Flask(__name__)
CORS(app)

API_KEY = os.getenv('REACT_APP_API_KEY')


def fetch_data(symbol, interval="1h", days=3):
    outputsize = 5000
    url = "https://api.twelvedata.com/time_series"
    params = {
        "symbol": symbol,
        "interval": interval,
        "outputsize": outputsize,
        "apikey": API_KEY,
    }

    response = requests.get(url, params=params)
    data = response.json()

    if "values" not in data:
        raise Exception(f"APIエラー: {data}")

    sorted_data = sorted(data["values"], key=lambda x: x["datetime"])
    df = pd.DataFrame(sorted_data)
    df['close'] = df['close'].astype(float)
    df['volume'] = df['volume'].astype(float)
    return df


def add_indicators(df):
    df['sma'] = SMAIndicator(df['close'], window=14).sma_indicator()
    df['rsi'] = RSIIndicator(df['close'], window=14).rsi()
    macd = MACD(df['close'])
    df['macd'] = macd.macd()
    df['signalLine'] = macd.macd_signal()
    return df


def generate_signals(df):
    signals = []
    buy_count = 0
    sell_count = 0
    avg_volume = df['volume'].rolling(window=20).mean()

    for i in range(len(df)):
        if i == 0 or pd.isna(df.loc[i, 'sma']) or pd.isna(df.loc[i, 'rsi']) or pd.isna(df.loc[i, 'macd']) or pd.isna(df.loc[i, 'signalLine']):
            signals.append(None)
            continue
        prev = df.loc[i - 1]
        curr = df.loc[i]
        score = 0

        # RSI 判定（35/65）
        if curr['rsi'] < 35:
            score += 1
        elif curr['rsi'] > 65:
            score -= 1

        # SMA クロス
        if prev['close'] < prev['sma'] and curr['close'] > curr['sma']:
            score += 1
        elif prev['close'] > prev['sma'] and curr['close'] < curr['sma']:
            score -= 1

        # 過去3本より上昇
        if i >= 3 and curr['close'] > df.loc[i - 3, 'close']:
            score += 1

        # MACD クロス
        macd_hist_prev = prev['macd'] - prev['signalLine']
        macd_hist_curr = curr['macd'] - curr['signalLine']
        if macd_hist_prev < 0 and macd_hist_curr > 0 and (macd_hist_curr - macd_hist_prev) > 0.05:
            score += 1
        elif macd_hist_prev > 0 and macd_hist_curr < 0 and (macd_hist_prev - macd_hist_curr) > 0.05:
            score -= 1

        # 出来高加点
        if not pd.isna(avg_volume[i]) and curr['volume'] > avg_volume[i]:
            score += 1

        if score >= 4:
            signals.append('BUY')
            buy_count += 1
        elif score <= -1:
            signals.append('SELL')
            sell_count += 1
        else:
            signals.append(None)

    print(f"BUYシグナル数: {buy_count}, SELLシグナル数: {sell_count}")

    # ポジション管理
    filtered_signals = []
    position = False
    for sig in signals:
        if sig == 'BUY':
            if not position:
                filtered_signals.append('BUY')
                position = True
            else:
                filtered_signals.append(None)
        elif sig == 'SELL':
            if position:
                filtered_signals.append('SELL')
                position = False
            else:
                filtered_signals.append(None)
        else:
            filtered_signals.append(None)

    df['signal'] = filtered_signals
    return df


def backtest_with_signals(df):
    position = None
    entry_price = 0
    max_price = 0
    total_profit = 0
    trades = 0
    win_trades = 0
    lose_trades = 0
    profits = []
    trailing_stop_pct = 0.05

    for i, row in df.iterrows():
        close = row['close']
        signal = row['signal']

        if position == 'LONG':
            # 高値更新
            if close > max_price:
                max_price = close

            # トレーリングストップ
            if close < max_price * (1 - trailing_stop_pct):
                profit = close - entry_price
                total_profit += profit
                profits.append(profit)
                trades += 1
                if profit > 0:
                    win_trades += 1
                else:
                    lose_trades += 1
                position = None
                entry_price = 0
                max_price = 0
                continue

        if signal == 'BUY' and position is None:
            position = 'LONG'
            entry_price = close
            max_price = close

        elif signal == 'SELL' and position == 'LONG':
            profit = close - entry_price
            total_profit += profit
            profits.append(profit)
            trades += 1
            if profit > 0:
                win_trades += 1
            else:
                lose_trades += 1
            position = None
            entry_price = 0
            max_price = 0

    # 最後のロウソクで決済
    if position == 'LONG':
        profit = df['close'].iloc[-1] - entry_price
        total_profit += profit
        profits.append(profit)
        trades += 1
        if profit > 0:
            win_trades += 1
        else:
            lose_trades += 1

    win_rate = round((win_trades / trades) * 100, 2) if trades > 0 else 0
    avg_profit = round(sum(profits) / trades, 2) if trades > 0 else 0
    
        # 勝ちトレードと負けトレードの平均を計算
    wins_profits = [p for p in profits if p > 0]
    losses_profits = [p for p in profits if p <= 0]

    avg_win = round(sum(wins_profits) / len(wins_profits), 2) if wins_profits else 0
    avg_loss = round(abs(sum(losses_profits) / len(losses_profits)), 2) if losses_profits else 0

    rr_ratio = round(avg_win / avg_loss, 2) if avg_loss != 0 else None

    return {
        "total_profit": round(total_profit, 2),
        "trades": trades,
        "wins": win_trades,
        "losses": lose_trades,
        "win_rate_percent": win_rate,
        "average_profit_per_trade": avg_profit,
        "average_win": avg_win,
        "average_loss": avg_loss,
        "risk_reward_ratio": rr_ratio,
        "last_close": df['close'].iloc[-1]
    }


@app.route("/backtest", methods=["GET"])
def backtest():
    symbol = request.args.get("symbol", "AAPL")
    try:
        raw_df = fetch_data(symbol, interval="1h")
        df = add_indicators(raw_df)
        df = generate_signals(df)
        result = backtest_with_signals(df)
        return jsonify(result)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


if __name__ == "__main__":
    app.run(port=5002, debug=True)
```

## コード全体の構成
セクション|概要
|-|-|
fetch_data|TwelveData APIを使って株価データを取得
add_indicators|RSI・SMA・MACDを計算してDataFrameに追加
generate_signals|テクニカル指標を使って売買シグナルを生成（BUY/SELL）
backtest_with_signals|売買シグナルに基づいて利益・勝率・リスクリワードなどを算出
Flaskルート /backtest|クライアントからGETリクエストを受けて、上記ロジックを実行し、JSONで返却

## 🧱 各関数の詳細解説

### fetch_data(symbol, interval="1h", days=3)
•	TwelveData API を使用して symbol（例：AAPL）の時系列データを取得。
•	データは「最新が先頭」で返されるので、datetime昇順にソート。
•	close と volume を float に変換。

### add_indicators(df)
•	ta ライブラリで以下の指標を計算：
•	SMA（単純移動平均）：14期間
•	RSI（相対力指数）：14期間
•	MACD と シグナルライン

### generate_signals(df)
•	各指標に基づいてスコア加算・減算し、スコア >= 4で”BUY”、<= -1で”SELL” と判定。
•	ポジション管理ロジックあり（連続BUYやSELLの抑制）。
<br>
シグナルスコア加点ロジック
条件|スコア|
|-|-|
RSIが35以下 → 買いサイン|+1
RSIが65以上 → 売りサイン|-1
SMAクロス（下→上）|+1
SMAクロス（上→下）|-1
3本前より株価上昇|+1
MACDクロス（下→上）で差が0.05以上|+1
MACDクロス（上→下）で差が0.05以上|-1
出来高が20期間移動平均より多い|+1

### backtest_with_signals(df)
•	シグナルに従ってポジションを持ち、「トレーリングストップ（5%）」で利益確定。
•	成績の評価を以下の指標で返す：
指標|説明
|-|-|
total_profit|総利益
trades|総トレード回数
wins / losses|勝ち／負けトレード数
win_rate_percent|勝率
average_profit_per_trade|1トレードあたりの平均利益
average_win / average_loss|勝ち／負けの平均損益
risk_reward_ratio|リスクリワード（平均勝ち ÷ 平均負け）
last_close|最終時点の株価


### /backtest Flaskルート
•	URL例: http://localhost:5002/backtest?symbol=AAPL
•	symbolに基づいてデータを取得し、全ロジックを流して結果をJSONで返す。