Files
PineSript/Analysis/Program.cs
2026-10-20 17:57:28 +09:00

1760 lines
90 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using Microsoft.Extensions.Configuration;
using PineScriptV6;
using Serilog;
using Analysis;
using Analysis.Tools;
// ════════════════════════════════════════════════════════════════
// 설정 로드
// ════════════════════════════════════════════════════════════════
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
var section = config.GetSection("Analysis");
string dbFolder = section["DbFolder"] ?? "./Data";
string scriptPath = section["ScriptPath"] ?? "./Script/example.pine";
string outputCsv = section["OutputCsv"] ?? "./Results/grid_results.csv";
int warmupBars = int.Parse(section["WarmupBars"] ?? "300");
// 대상 종목 설정
var targetSection = section.GetSection("Target");
string targetExchange = targetSection["Exchange"] ?? "Bitget";
string targetProductType = targetSection["ProductType"] ?? "USDT-FUTURES";
string targetSymbol = targetSection["Symbol"] ?? "XRPUSDT";
string targetInterval = targetSection["Interval"] ?? "30m";
// ── 출력 폴더 구조: Results/{종목}_{TF}/{실행날짜}/ ──
string runDate = DateTime.Now.ToString("yyyyMMdd");
string symbolDir = Path.Combine("Results", $"{targetSymbol}_{targetInterval}"); // 종목 단위(체크포인트 등 안정 위치)
string outRoot = Path.Combine(symbolDir, runDate); // 날짜별 최종 산출물
// 최종 산출물(Excel/CSV) — 날짜 폴더에 저장
string Out(string file) { Directory.CreateDirectory(outRoot); return Path.Combine(outRoot, file); }
// 체크포인트/플래그 — 날짜 무관(이어하기용, 종목 폴더)
string Ckpt(string file) { Directory.CreateDirectory(symbolDir); return Path.Combine(symbolDir, file); }
// ════════════════════════════════════════════════════════════════
// 커맨드 라우팅
// ════════════════════════════════════════════════════════════════
string command = args.Length > 0 ? args[0].ToLower() : "grid";
switch (command)
{
case "import-csv":
await RunImportCsv(args);
break;
case "download":
await RunDownload(args);
break;
case "grid":
RunGridSearch();
break;
case "test":
RunSingleTest(args);
break;
case "compare":
RunCompare();
break;
case "monthly-best":
RunMonthlyBest();
break;
case "correlate":
RunCorrelate();
break;
case "volfilter":
RunVolFilter(args);
break;
case "tune":
RunTune(args);
break;
case "entrystats":
RunEntryStats(args);
break;
case "tunecf":
RunTuneCf();
break;
case "bsweep":
RunBSweep();
break;
default:
PrintUsage();
break;
}
return;
// ════════════════════════════════════════════════════════════════
// [커맨드 1] import-csv — TradingView CSV → .db 변환
// ════════════════════════════════════════════════════════════════
async Task RunImportCsv(string[] cmdArgs)
{
if (cmdArgs.Length < 2)
{
Console.WriteLine("사용법: Analysis import-csv <CSV파일경로> [타임프레임] [종목]");
Console.WriteLine(" 예: Analysis import-csv \"C:\\data\\BITGET_BTCUSDT.P, 30.csv\" 30m BTCUSDT");
return;
}
string csvPath = cmdArgs[1];
string interval = cmdArgs.Length > 2 ? cmdArgs[2] : "30m";
string symbol = cmdArgs.Length > 3 ? cmdArgs[3] : targetSymbol; // 종목 미지정 시 Target 사용
TvCsvImporter.Import(csvPath, dbFolder,
targetExchange, targetProductType, symbol, interval);
}
// ════════════════════════════════════════════════════════════════
// [커맨드 2] download — Bitget API → .db 다운로드
// ════════════════════════════════════════════════════════════════
async Task RunDownload(string[] cmdArgs)
{
string granularity = cmdArgs.Length > 1 ? cmdArgs[1] : "30m";
int maxDays = cmdArgs.Length > 2 ? int.Parse(cmdArgs[2]) : 62;
var downloader = new BitgetDownloader();
await downloader.DownloadAsync(
dbFolder, targetSymbol, targetProductType.ToLower(),
granularity, maxDays);
}
// ════════════════════════════════════════════════════════════════
// [커맨드 3] grid — 파라미터 그리드 서치 (기본)
// ════════════════════════════════════════════════════════════════
void RunGridSearch()
{
// Serilog — 그리드 서치 시 Warning 이상만 출력 (성능)
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
// 파라미터 범위 로드
var paramSection = section.GetSection("Parameters");
var lwRange = LoadRange(paramSection, "lengthWindow");
var rpRange = LoadRange(paramSection, "rangePeriod");
var slwRange = LoadRange(paramSection, "shortLengthWindow");
var srpRange = LoadRange(paramSection, "shortRangePeriod");
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 파라미터 그리드 서치 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
// DB 로드
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
// 그리드 조합 생성
var combos = GridSearchRunner.GenerateCombinations(lwRange, rpRange, slwRange, srpRange);
Console.WriteLine($"[Grid] 파라미터 조합 수: {combos.Count}개");
Console.WriteLine($" lengthWindow: {lwRange.Min} ~ {lwRange.Max} (step {lwRange.Step})");
Console.WriteLine($" rangePeriod: {rpRange.Min} ~ {rpRange.Max} (step {rpRange.Step})");
Console.WriteLine($" shortLengthWindow: {slwRange.Min} ~ {slwRange.Max} (step {slwRange.Step})");
Console.WriteLine($" shortRangePeriod: {srpRange.Min} ~ {srpRange.Max} (step {srpRange.Step})");
Console.WriteLine();
// 그리드 서치 실행
var runner = new GridSearchRunner(scriptPath, warmupBars);
var sw = Stopwatch.StartNew();
var results = runner.RunAll(allCandles, combos);
sw.Stop();
Console.WriteLine();
Console.WriteLine($"[완료] 총 소요 시간: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
// 상위 10개 결과 — 연간/월별 포함 출력
var top10 = results.OrderByDescending(r => r.TotalProfitPercent).Take(10).ToList();
Console.WriteLine($"═══════════════════════════════════════════════════════════════");
Console.WriteLine($" 상위 10개 조합 (총 수익률 기준)");
Console.WriteLine($" 대상: {targetExchange}/{targetProductType}/{targetSymbol}");
Console.WriteLine($" 기간: {dataFrom} ~ {dataTo}");
Console.WriteLine($"═══════════════════════════════════════════════════════════════");
Console.WriteLine();
for (int i = 0; i < top10.Count; i++)
{
PrintDetailedResult(top10[i], i + 1);
}
// CSV 저장
string targetLabel = $"{targetExchange}/{targetProductType}/{targetSymbol}";
string gridCsv = Out("grid_results.csv");
SaveResultsCsv(results, gridCsv, targetLabel, dataFrom, dataTo);
Console.WriteLine($"[CSV] 결과 저장 완료: {gridCsv}");
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 4] test — 단일 파라미터 테스트
// ════════════════════════════════════════════════════════════════
void RunSingleTest(string[] cmdArgs)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
// 파라미터 파싱: test [lw] [rp] [slw] [srp]
int lw = cmdArgs.Length > 1 ? int.Parse(cmdArgs[1]) : 12;
int rp = cmdArgs.Length > 2 ? int.Parse(cmdArgs[2]) : 240;
int slw = cmdArgs.Length > 3 ? int.Parse(cmdArgs[3]) : 4;
int srp = cmdArgs.Length > 4 ? int.Parse(cmdArgs[4]) : 40;
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 단일 파라미터 테스트 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
// DB 로드
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
Console.WriteLine($"[Test] 파라미터: LW={lw} RP={rp} SLW={slw} SRP={srp}");
Console.WriteLine();
// 단일 조합 실행
var combo = new GridSearchRunner.ParamCombo(lw, rp, slw, srp);
var runner = new GridSearchRunner(scriptPath, warmupBars);
var sw = Stopwatch.StartNew();
var results = runner.RunAll(allCandles, new List<GridSearchRunner.ParamCombo> { combo });
sw.Stop();
Console.WriteLine();
Console.WriteLine($"[완료] 소요 시간: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
if (results.Count > 0)
{
PrintDetailedResult(results[0], rank: 0, showTrades: true);
// ── Excel 출력 (달별 시트 + 진입시 임계값) ──
string xlsxPath = Out($"trades_LW{lw}_RP{rp}_SLW{slw}_SRP{srp}.xlsx");
string targetLabel = $"{targetExchange}/{targetProductType}/{targetSymbol}";
try
{
string saved = ExcelExporter.ExportTrades(
results[0], xlsxPath, targetLabel, dataFrom, dataTo);
Console.WriteLine($"[Excel] 거래 내역 저장 완료 (달별 시트): {saved}");
}
catch (Exception ex)
{
Console.WriteLine($"[Excel] 저장 실패: {ex.Message}");
}
}
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 5] compare — A안(적응형) vs B안(고정 임계값) 비교
// ════════════════════════════════════════════════════════════════
void RunCompare()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — A안(적응형) vs B안(고정 임계값) 비교 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
// 범위 로드
var paramSection = section.GetSection("Parameters");
var lwRange = LoadRange(paramSection, "lengthWindow");
var rpRange = LoadRange(paramSection, "rangePeriod");
var slwRange = LoadRange(paramSection, "shortLengthWindow");
var srpRange = LoadRange(paramSection, "shortRangePeriod");
var fixedSection = section.GetSection("FixedThresholds");
var tlRange = LoadRangeF(fixedSection, "rangeThresholdLong");
var tsRange = LoadRangeF(fixedSection, "rangeThresholdShort");
// 두 스크립트용 러너
var runnerA = new GridSearchRunner(scriptPath, warmupBars); // example.pine (적응형)
string fixedScriptPath = Path.Combine(
Path.GetDirectoryName(scriptPath) ?? ".", "example_fixed.pine");
var runnerB = new GridSearchRunner(fixedScriptPath, warmupBars); // example_fixed.pine (고정)
// 방향별 수익 합 (롱/숏 분리 — 두 방식 모두 독립적이므로 따로 최적화)
static double DirPnl(ProfitCalculator.AnalysisResult r, string dir) =>
r.Trades.Where(t => t.Direction == dir).Sum(t => t.ProfitPercent);
var sw = Stopwatch.StartNew();
// ── A안 Long 탐색 (lw × rp, short는 스크립트 기본 4/40) ──
Console.WriteLine("[A안] Long 탐색 (lengthWindow × rangePeriod)...");
double bestALong = double.NegativeInfinity; int bestLw = 0, bestRp = 0;
for (int lw = lwRange.Min; lw <= lwRange.Max; lw += lwRange.Step)
for (int rp = rpRange.Min; rp <= rpRange.Max; rp += rpRange.Step)
{
var r = runnerA.RunWithReplacements(allCandles, new Dictionary<string, string> {
["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString() });
double pnl = DirPnl(r, "Long");
if (pnl > bestALong) { bestALong = pnl; bestLw = lw; bestRp = rp; }
}
Console.WriteLine($" → 최적: LW={bestLw} RP={bestRp} | Long수익={bestALong:F2}%");
// ── A안 Short 탐색 (slw × srp, long은 기본 12/240) ──
Console.WriteLine("[A안] Short 탐색 (shortLengthWindow × shortRangePeriod)...");
double bestAShort = double.NegativeInfinity; int bestSlw = 0, bestSrp = 0;
for (int slw = slwRange.Min; slw <= slwRange.Max; slw += slwRange.Step)
for (int srp = srpRange.Min; srp <= srpRange.Max; srp += srpRange.Step)
{
var r = runnerA.RunWithReplacements(allCandles, new Dictionary<string, string> {
["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString() });
double pnl = DirPnl(r, "Short");
if (pnl > bestAShort) { bestAShort = pnl; bestSlw = slw; bestSrp = srp; }
}
Console.WriteLine($" → 최적: SLW={bestSlw} SRP={bestSrp} | Short수익={bestAShort:F2}%");
// ── B안 Long 탐색 (고정 LongThreshold) ──
Console.WriteLine("[B안] Long 탐색 (고정 rangeThresholdLongPercent)...");
double bestBLong = double.NegativeInfinity; double bestTL = 0;
foreach (double tl in EnumerateF(tlRange))
{
var r = runnerB.RunWithReplacements(allCandles, new Dictionary<string, string> {
["rangeThresholdLongPercent"] = tl.ToString("0.####", CultureInfo.InvariantCulture) });
double pnl = DirPnl(r, "Long");
if (pnl > bestBLong) { bestBLong = pnl; bestTL = tl; }
}
Console.WriteLine($" → 최적: LongThreshold={bestTL:F2}% | Long수익={bestBLong:F2}%");
// ── B안 Short 탐색 (고정 ShortThreshold) ──
Console.WriteLine("[B안] Short 탐색 (고정 rangeThresholdShortPercent)...");
double bestBShort = double.NegativeInfinity; double bestTS = 0;
foreach (double ts in EnumerateF(tsRange))
{
var r = runnerB.RunWithReplacements(allCandles, new Dictionary<string, string> {
["rangeThresholdShortPercent"] = ts.ToString("0.####", CultureInfo.InvariantCulture) });
double pnl = DirPnl(r, "Short");
if (pnl > bestBShort) { bestBShort = pnl; bestTS = ts; }
}
Console.WriteLine($" → 최적: ShortThreshold={bestTS:F2}% | Short수익={bestBShort:F2}%");
sw.Stop();
Console.WriteLine();
Console.WriteLine($"[완료] 탐색 소요: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
// ── 비교표 ──
double aTotal = bestALong + bestAShort;
double bTotal = bestBLong + bestBShort;
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine($" A안(적응형) vs B안(고정 임계값) — {targetExchange}/{targetProductType}/{targetSymbol}");
Console.WriteLine($" 기간: {dataFrom} ~ {dataTo}");
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine($" {"방식",-12} {"Long%",10} {"Short%",10} {"합계%",10} 설정");
Console.WriteLine($" {new string('─', 72)}");
Console.WriteLine($" {"A안 적응형",-12} {bestALong,10:F2} {bestAShort,10:F2} {aTotal,10:F2} LW={bestLw} RP={bestRp} SLW={bestSlw} SRP={bestSrp}");
Console.WriteLine($" {"B안 고정",-12} {bestBLong,10:F2} {bestBShort,10:F2} {bTotal,10:F2} TL={bestTL:F2}% TS={bestTS:F2}%");
Console.WriteLine();
Console.WriteLine($" ▶ 더 높은 총수익: {(aTotal >= bTotal ? "A안(적응형)" : "B안(고정)")} (차이 {Math.Abs(aTotal - bTotal):F2}%p)");
Console.WriteLine();
// ── 최적 조합으로 Excel 생성 (달별 시트) ──
string label = $"{targetExchange}/{targetProductType}/{targetSymbol}";
var aBest = runnerA.RunWithReplacements(allCandles, new Dictionary<string, string> {
["lengthWindow"] = bestLw.ToString(), ["rangePeriod"] = bestRp.ToString(),
["shortLengthWindow"] = bestSlw.ToString(), ["shortRangePeriod"] = bestSrp.ToString() });
var bBest = runnerB.RunWithReplacements(allCandles, new Dictionary<string, string> {
["rangeThresholdLongPercent"] = bestTL.ToString("0.####", CultureInfo.InvariantCulture),
["rangeThresholdShortPercent"] = bestTS.ToString("0.####", CultureInfo.InvariantCulture) });
try
{
string pa = ExcelExporter.ExportTrades(aBest,
Out("compare_A_adaptive_best.xlsx"),
label + " [A안 적응형 최적]", dataFrom, dataTo,
$"LW={bestLw} RP={bestRp} SLW={bestSlw} SRP={bestSrp} (적응형 임계값)");
string pb = ExcelExporter.ExportTrades(bBest,
Out("compare_B_fixed_best.xlsx"),
label + " [B안 고정 최적]", dataFrom, dataTo,
$"LongThreshold={bestTL:F2}% ShortThreshold={bestTS:F2}% (고정 임계값)");
Console.WriteLine($"[Excel] A안 최적 거래내역: {pa}");
Console.WriteLine($"[Excel] B안 최적 거래내역: {pb}");
}
catch (Exception ex)
{
Console.WriteLine($"[Excel] 저장 실패: {ex.Message}");
}
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 6] monthly-best — 월별 최적 파라미터 (Long/Short 완전 분리)
// ════════════════════════════════════════════════════════════════
void RunMonthlyBest()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 월별 최적 파라미터 (Long/Short 분리) ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
var paramSection = section.GetSection("Parameters");
var lwRange = LoadRange(paramSection, "lengthWindow");
var rpRange = LoadRange(paramSection, "rangePeriod");
var slwRange = LoadRange(paramSection, "shortLengthWindow");
var srpRange = LoadRange(paramSection, "shortRangePeriod");
var fixedSection = section.GetSection("FixedThresholds");
var tlRange = LoadRangeF(fixedSection, "rangeThresholdLong");
var tsRange = LoadRangeF(fixedSection, "rangeThresholdShort");
var runnerA = new GridSearchRunner(scriptPath, warmupBars);
string fixedScriptPath = Path.Combine(
Path.GetDirectoryName(scriptPath) ?? ".", "example_fixed.pine");
var runnerB = new GridSearchRunner(fixedScriptPath, warmupBars);
// 전체 월 목록
var months = allCandles
.Select(c => (c.Time.Year, c.Time.Month))
.Distinct().OrderBy(m => m.Year).ThenBy(m => m.Month).ToList();
// 조합 실행 → 조합별 (월→방향수익) 수집
List<(string label, Dictionary<(int, int), double> monthly)> Collect(
GridSearchRunner runner,
List<(Dictionary<string, string> repl, string label)> combos, string dir)
{
var outList = new List<(string, Dictionary<(int, int), double>)>();
foreach (var (repl, label) in combos)
{
var r = runner.RunWithReplacements(allCandles, repl);
var m = r.Trades.Where(t => t.Direction == dir)
.GroupBy(t => (t.ExitTime.Year, t.ExitTime.Month))
.ToDictionary(g => g.Key, g => g.Sum(t => t.ProfitPercent));
outList.Add((label, m));
}
return outList;
}
// 월별 최적 선택 (그 달에 실제 거래한 조합 중 최고 수익)
(string label, double pnl) BestForMonth(
List<(string label, Dictionary<(int, int), double> monthly)> data, (int, int) ym)
{
string bestL = "(거래없음)"; double best = double.NegativeInfinity;
foreach (var (label, m) in data)
{
if (!m.TryGetValue(ym, out var v)) continue; // 그 달 거래 없으면 후보 제외
if (v > best) { best = v; bestL = label; }
}
return double.IsNegativeInfinity(best) ? (bestL, 0.0) : (bestL, best);
}
// 조합 리스트 구성
var aLongCombos = new List<(Dictionary<string, string>, string)>();
for (int lw = lwRange.Min; lw <= lwRange.Max; lw += lwRange.Step)
for (int rp = rpRange.Min; rp <= rpRange.Max; rp += rpRange.Step)
aLongCombos.Add((new() { ["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString() }, $"LW={lw} RP={rp}"));
var aShortCombos = new List<(Dictionary<string, string>, string)>();
for (int slw = slwRange.Min; slw <= slwRange.Max; slw += slwRange.Step)
for (int srp = srpRange.Min; srp <= srpRange.Max; srp += srpRange.Step)
aShortCombos.Add((new() { ["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString() }, $"SLW={slw} SRP={srp}"));
var bLongCombos = new List<(Dictionary<string, string>, string)>();
foreach (double tl in EnumerateF(tlRange))
bLongCombos.Add((new() { ["rangeThresholdLongPercent"] = tl.ToString("0.####", CultureInfo.InvariantCulture) }, $"TL={tl:F2}"));
var bShortCombos = new List<(Dictionary<string, string>, string)>();
foreach (double ts in EnumerateF(tsRange))
bShortCombos.Add((new() { ["rangeThresholdShortPercent"] = ts.ToString("0.####", CultureInfo.InvariantCulture) }, $"TS={ts:F2}"));
Console.WriteLine($"[수집] A안 Long {aLongCombos.Count} / Short {aShortCombos.Count} / " +
$"B안 Long {bLongCombos.Count} / Short {bShortCombos.Count} 조합 실행...");
var sw = Stopwatch.StartNew();
var aLong = Collect(runnerA, aLongCombos, "Long");
var aShort = Collect(runnerA, aShortCombos, "Short");
var bLong = Collect(runnerB, bLongCombos, "Long");
var bShort = Collect(runnerB, bShortCombos, "Short");
sw.Stop();
Console.WriteLine($"[완료] 소요: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
// 월별 최적 표
var rows = new List<ExcelExporter.MonthlyBestRow>();
Console.WriteLine($" {"월",-9} {"A_Long",-13}{"%",7} {"A_Short",-15}{"%",7} {"B_Long",-7}{"%",7} {"B_Short",-7}{"%",7}");
Console.WriteLine($" {new string('─', 92)}");
foreach (var ym in months)
{
var (al, alp) = BestForMonth(aLong, ym);
var (ash, asp) = BestForMonth(aShort, ym);
var (bl, blp) = BestForMonth(bLong, ym);
var (bs, bsp) = BestForMonth(bShort, ym);
string mlabel = $"{ym.Item1}-{ym.Item2:D2}";
rows.Add(new ExcelExporter.MonthlyBestRow(mlabel, al, alp, ash, asp, bl, blp, bs, bsp));
Console.WriteLine($" {mlabel,-9} {al,-13}{alp,7:F2} {ash,-15}{asp,7:F2} {bl,-7}{blp,7:F2} {bs,-7}{bsp,7:F2}");
}
Console.WriteLine();
string label = $"{targetExchange}/{targetProductType}/{targetSymbol}";
try
{
string p = ExcelExporter.ExportMonthlyBest(rows,
Out("monthly_best.xlsx"), label, dataFrom, dataTo);
Console.WriteLine($"[Excel] 월별 최적 표 저장: {p}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 저장 실패: {ex.Message}"); }
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 7] correlate — 월별 시장특성 vs 최적 파라미터 상관관계
// ════════════════════════════════════════════════════════════════
void RunCorrelate()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 시장특성 vs 최적 파라미터 상관분석 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
// ── Step 1: 월별 시장특성 계산 ──
var monthGroups = allCandles
.GroupBy(c => (c.Time.Year, c.Time.Month))
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
.ToList();
var months = monthGroups.Select(g => (g.Key.Year, g.Key.Month)).ToList();
int N = months.Count;
string[] featNames = { "vol%", "ret%", "absRet%", "range%", "volStd", "avgVol", "volCV", "volMoM%", "volRel3" };
var feat = new double[featNames.Length][];
for (int f = 0; f < featNames.Length; f++) feat[f] = new double[N];
var avgVolArr = new double[N];
for (int i = 0; i < N; i++)
{
var cs = monthGroups[i].OrderBy(c => c.Time).ToList();
double firstClose = (double)cs[0].Close, lastClose = (double)cs[^1].Close;
var hl = cs.Select(c => (double)(c.High - c.Low) / (double)c.Close * 100).ToList();
double vol = hl.Average();
double volStd = Math.Sqrt(hl.Select(v => (v - vol) * (v - vol)).Average());
double ret = (lastClose - firstClose) / firstClose * 100;
double hi = (double)cs.Max(c => c.High), lo = (double)cs.Min(c => c.Low);
double avgClose = cs.Average(c => (double)c.Close);
double rangePct = (hi - lo) / avgClose * 100;
var vols = cs.Select(c => (double)c.Volume).ToList();
double avgVol = vols.Average();
double volSd = Math.Sqrt(vols.Select(v => (v - avgVol) * (v - avgVol)).Average());
double volCV = avgVol > 0 ? volSd / avgVol : 0; // 월내 거래량 변동계수(스파이크성)
avgVolArr[i] = avgVol;
feat[0][i] = vol; feat[1][i] = ret; feat[2][i] = Math.Abs(ret);
feat[3][i] = rangePct; feat[4][i] = volStd; feat[5][i] = avgVol; feat[6][i] = volCV;
}
// 정규화 거래량 특성 (비정상성 제거) — 직전/최근 대비
for (int i = 0; i < N; i++)
{
feat[7][i] = i == 0 ? double.NaN : (avgVolArr[i] / avgVolArr[i - 1] - 1) * 100; // 전월 대비 %
if (i == 0) { feat[8][i] = double.NaN; continue; }
int from = Math.Max(0, i - 3); double trail = 0; int cnt = 0;
for (int j = from; j < i; j++) { trail += avgVolArr[j]; cnt++; }
double trailAvg = cnt > 0 ? trail / cnt : double.NaN;
feat[8][i] = trailAvg > 0 ? avgVolArr[i] / trailAvg : double.NaN; // 최근3개월 대비 배율
}
// ── Step 2: 월별 최적 파라미터(숫자) 수집 ──
var paramSection = section.GetSection("Parameters");
var lwRange = LoadRange(paramSection, "lengthWindow");
var rpRange = LoadRange(paramSection, "rangePeriod");
var slwRange = LoadRange(paramSection, "shortLengthWindow");
var srpRange = LoadRange(paramSection, "shortRangePeriod");
var fixedSection = section.GetSection("FixedThresholds");
var tlRange = LoadRangeF(fixedSection, "rangeThresholdLong");
var tsRange = LoadRangeF(fixedSection, "rangeThresholdShort");
var runnerA = new GridSearchRunner(scriptPath, warmupBars);
string fixedScriptPath = Path.Combine(Path.GetDirectoryName(scriptPath) ?? ".", "example_fixed.pine");
var runnerB = new GridSearchRunner(fixedScriptPath, warmupBars);
// 각 조합을 실행해, 월별 그 방향 최적 조합의 숫자 파라미터를 뽑음
Dictionary<(int, int), double[]> BestNums(
GridSearchRunner runner,
List<(double[] nums, Dictionary<string, string> repl)> combos, string dir)
{
var runs = new List<(double[] nums, Dictionary<(int, int), double> m)>();
foreach (var (nums, repl) in combos)
{
var r = runner.RunWithReplacements(allCandles, repl);
var mm = r.Trades.Where(t => t.Direction == dir)
.GroupBy(t => (t.ExitTime.Year, t.ExitTime.Month))
.ToDictionary(g => g.Key, g => g.Sum(t => t.ProfitPercent));
runs.Add((nums, mm));
}
var outD = new Dictionary<(int, int), double[]>();
foreach (var ym in months)
{
double[]? best = null; double bestPnl = double.NegativeInfinity;
foreach (var (nums, mm) in runs)
if (mm.TryGetValue(ym, out var v) && v > bestPnl) { bestPnl = v; best = nums; }
outD[ym] = best ?? Array.Empty<double>();
}
return outD;
}
var aLongCombos = new List<(double[], Dictionary<string, string>)>();
for (int lw = lwRange.Min; lw <= lwRange.Max; lw += lwRange.Step)
for (int rp = rpRange.Min; rp <= rpRange.Max; rp += rpRange.Step)
aLongCombos.Add((new double[] { lw, rp },
new() { ["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString() }));
var aShortCombos = new List<(double[], Dictionary<string, string>)>();
for (int slw = slwRange.Min; slw <= slwRange.Max; slw += slwRange.Step)
for (int srp = srpRange.Min; srp <= srpRange.Max; srp += srpRange.Step)
aShortCombos.Add((new double[] { slw, srp },
new() { ["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString() }));
var bLongCombos = new List<(double[], Dictionary<string, string>)>();
foreach (double tl in EnumerateF(tlRange))
bLongCombos.Add((new double[] { tl },
new() { ["rangeThresholdLongPercent"] = tl.ToString("0.####", CultureInfo.InvariantCulture) }));
var bShortCombos = new List<(double[], Dictionary<string, string>)>();
foreach (double ts in EnumerateF(tsRange))
bShortCombos.Add((new double[] { ts },
new() { ["rangeThresholdShortPercent"] = ts.ToString("0.####", CultureInfo.InvariantCulture) }));
Console.WriteLine("[수집] 월별 최적 파라미터 추출 중...");
var sw = Stopwatch.StartNew();
var aL = BestNums(runnerA, aLongCombos, "Long");
var aS = BestNums(runnerA, aShortCombos, "Short");
var bL = BestNums(runnerB, bLongCombos, "Long");
var bS = BestNums(runnerB, bShortCombos, "Short");
sw.Stop();
Console.WriteLine($"[완료] 소요: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
// 타겟(최적값) 배열 — 거래없는 달은 NaN
string[] tgtNames = { "LW*", "RP*", "SLW*", "SRP*", "TL*", "TS*" };
var tgt = new double[tgtNames.Length][];
for (int t = 0; t < tgtNames.Length; t++) tgt[t] = new double[N];
for (int i = 0; i < N; i++)
{
var ym = months[i];
tgt[0][i] = aL[ym].Length > 0 ? aL[ym][0] : double.NaN;
tgt[1][i] = aL[ym].Length > 0 ? aL[ym][1] : double.NaN;
tgt[2][i] = aS[ym].Length > 0 ? aS[ym][0] : double.NaN;
tgt[3][i] = aS[ym].Length > 0 ? aS[ym][1] : double.NaN;
tgt[4][i] = bL[ym].Length > 0 ? bL[ym][0] : double.NaN;
tgt[5][i] = bS[ym].Length > 0 ? bS[ym][0] : double.NaN;
}
// ── Step 3: 상관계수 (동시점 + 1개월 시차) ──
var corrNow = new double[featNames.Length][];
var corrLag = new double[featNames.Length][];
for (int f = 0; f < featNames.Length; f++)
{
corrNow[f] = new double[tgtNames.Length];
corrLag[f] = new double[tgtNames.Length];
for (int t = 0; t < tgtNames.Length; t++)
{
corrNow[f][t] = Pearson(feat[f], tgt[t]);
corrLag[f][t] = Pearson(Shift(feat[f]), tgt[t]); // 전달 특성 → 이번달 최적
}
}
// ── 콘솔 출력 ──
void PrintMatrix(string title, double[][] c)
{
Console.WriteLine(title);
Console.Write($" {"특성\\최적",-10}");
foreach (var tn in tgtNames) Console.Write($"{tn,8}");
Console.WriteLine();
for (int f = 0; f < featNames.Length; f++)
{
Console.Write($" {featNames[f],-10}");
for (int t = 0; t < tgtNames.Length; t++)
{
double r = c[f][t];
string s = double.IsNaN(r) ? " - " : r.ToString("+0.00;-0.00");
Console.Write($"{(Math.Abs(r) >= 0.47 ? "*" : " ")}{s,7}");
}
Console.WriteLine();
}
Console.WriteLine(" (* = |r|≥0.47, 18개월 기준 p<0.05 수준 / 그 외는 약함)");
Console.WriteLine();
}
PrintMatrix("[ 동시점 상관: 그 달 특성 ↔ 그 달 최적값 ]", corrNow);
PrintMatrix("[ 1개월 시차 상관: 전달 특성 ↔ 이번달 최적값 ← 실전 예측용 ]", corrLag);
// ── Excel 출력 ──
try
{
string outPath = Out("correlate.xlsx");
using var wb = new XLWorkbook();
// 시트1: 월별 특성+최적값
var ws = wb.Worksheets.Add("월별특성_최적값");
ws.Cell(1, 1).Value = "월";
for (int f = 0; f < featNames.Length; f++) ws.Cell(1, 2 + f).Value = featNames[f];
for (int t = 0; t < tgtNames.Length; t++) ws.Cell(1, 2 + featNames.Length + t).Value = tgtNames[t];
ws.Range(1, 1, 1, 1 + featNames.Length + tgtNames.Length).Style.Font.Bold = true;
ws.Range(1, 1, 1, 1 + featNames.Length + tgtNames.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
ws.Range(1, 1, 1, 1 + featNames.Length + tgtNames.Length).Style.Font.FontColor = XLColor.White;
for (int i = 0; i < N; i++)
{
ws.Cell(2 + i, 1).Value = $"{months[i].Year}-{months[i].Month:D2}";
for (int f = 0; f < featNames.Length; f++)
{
var fc = ws.Cell(2 + i, 2 + f);
if (double.IsNaN(feat[f][i])) fc.Value = "-"; else fc.Value = Math.Round(feat[f][i], 4);
}
for (int t = 0; t < tgtNames.Length; t++)
ws.Cell(2 + i, 2 + featNames.Length + t).Value =
double.IsNaN(tgt[t][i]) ? "-" : Math.Round(tgt[t][i], 2);
}
ws.Columns().AdjustToContents(); ws.SheetView.FreezeRows(1);
// 시트2,3: 상관계수
void WriteCorr(string name, double[][] c)
{
var w = wb.Worksheets.Add(name);
w.Cell(1, 1).Value = "특성\\최적";
for (int t = 0; t < tgtNames.Length; t++) w.Cell(1, 2 + t).Value = tgtNames[t];
for (int f = 0; f < featNames.Length; f++)
{
w.Cell(2 + f, 1).Value = featNames[f];
for (int t = 0; t < tgtNames.Length; t++)
{
var cell = w.Cell(2 + f, 2 + t);
if (double.IsNaN(c[f][t])) { cell.Value = "-"; continue; }
cell.Value = Math.Round(c[f][t], 2);
if (Math.Abs(c[f][t]) >= 0.47)
{
cell.Style.Fill.BackgroundColor = c[f][t] > 0 ? XLColor.LightGreen : XLColor.LightSalmon;
cell.Style.Font.Bold = true;
}
}
}
w.Range(1, 1, 1, 1 + tgtNames.Length).Style.Font.Bold = true;
w.Column(1).Style.Font.Bold = true;
w.Columns().AdjustToContents();
}
WriteCorr("상관_동시점", corrNow);
WriteCorr("상관_1개월시차", corrLag);
wb.SaveAs(outPath);
Console.WriteLine($"[Excel] 상관분석 저장: {Path.GetFullPath(outPath)}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 저장 실패: {ex.Message}"); }
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 8] volfilter — 거래량 진입 필터 효과 실험
// ════════════════════════════════════════════════════════════════
void RunVolFilter(string[] cmdArgs)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
// 베이스 파라미터 (기본: A안 최적 LW=8 RP=60 SLW=8 SRP=50)
int lw = cmdArgs.Length > 1 ? int.Parse(cmdArgs[1]) : 8;
int rp = cmdArgs.Length > 2 ? int.Parse(cmdArgs[2]) : 60;
int slw = cmdArgs.Length > 3 ? int.Parse(cmdArgs[3]) : 8;
int srp = cmdArgs.Length > 4 ? int.Parse(cmdArgs[4]) : 50;
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 거래량 진입 필터 효과 실험 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine($" 베이스 파라미터: LW={lw} RP={rp} SLW={slw} SRP={srp}");
Console.WriteLine($" 필터 조건: volume > sma(volume, volWindow) × volMult");
Console.WriteLine($" (volMult=0 = 필터 없음 = 베이스라인)");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
string volScript = Path.Combine(Path.GetDirectoryName(scriptPath) ?? ".", "example_volfilter.pine");
var runner = new GridSearchRunner(volScript, warmupBars);
int[] windows = { 10, 20, 30, 48 };
double[] mults = { 0.0, 0.8, 1.0, 1.2, 1.5 };
var sw = Stopwatch.StartNew();
var results = new Dictionary<(int w, double m), (double total, int trades)>();
double baseline = double.NaN;
foreach (int w in windows)
foreach (double m in mults)
{
var repl = new Dictionary<string, string> {
["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString(),
["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString(),
["volWindow"] = w.ToString(),
["volMult"] = m.ToString("0.####", CultureInfo.InvariantCulture) };
var r = runner.RunWithReplacements(allCandles, repl);
results[(w, m)] = (r.TotalProfitPercent, r.Trades.Count);
if (m == 0.0) baseline = r.TotalProfitPercent;
}
sw.Stop();
Console.WriteLine($"[완료] 소요: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
// 표: 행=volWindow, 열=volMult (총수익%)
Console.WriteLine($" [ 총수익% — 행:volWindow / 열:volMult ]");
Console.Write($" {"win\\mult",-9}");
foreach (double m in mults) Console.Write($"{(m == 0 ? "없음" : m.ToString("0.0")),9}");
Console.WriteLine();
Console.WriteLine($" {new string('─', 9 + mults.Length * 9)}");
foreach (int w in windows)
{
Console.Write($" {w,-9}");
foreach (double m in mults)
{
var (total, _) = results[(w, m)];
string mark = (m > 0 && total > baseline) ? "+" : " ";
Console.Write($"{mark}{total,8:F2}");
}
Console.WriteLine();
}
Console.WriteLine();
var best = results.Where(kv => kv.Key.m > 0)
.OrderByDescending(kv => kv.Value.total).First();
Console.WriteLine($" 베이스라인(필터없음): {baseline:F2}%");
Console.WriteLine($" 필터 최고: {best.Value.total:F2}% " +
$"@ volWindow={best.Key.w} volMult={best.Key.m:F1} (거래 {best.Value.trades}건)");
double diff = best.Value.total - baseline;
Console.WriteLine($" ▶ 거래량 필터 효과: {(diff >= 0 ? "+" : "")}{diff:F2}%p " +
$"→ {(diff > 0 ? "개선됨" : "개선 없음")}");
Console.WriteLine();
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 9] tune — 학습/검증 분할로 안정적 고정 윈도우 찾기 (A안)
// ════════════════════════════════════════════════════════════════
void RunTune(string[] cmdArgs)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — tune (학습/검증 분할 윈도우 최적화) ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
var paramSection = section.GetSection("Parameters");
var lwR = LoadRange(paramSection, "lengthWindow");
var rpR = LoadRange(paramSection, "rangePeriod");
var slwR = LoadRange(paramSection, "shortLengthWindow");
var srpR = LoadRange(paramSection, "shortRangePeriod");
var runner = new GridSearchRunner(scriptPath, warmupBars);
int learnYear = allCandles.Min(c => c.Time.Year);
Console.WriteLine($" 학습={learnYear} / 검증={learnYear + 1}~ | " +
$"Long: LW {lwR.Min}~{lwR.Max}/{lwR.Step} × RP {rpR.Min}~{rpR.Max}/{rpR.Step} | " +
$"Short: SLW {slwR.Min}~{slwR.Max}/{slwR.Step} × SRP {srpR.Min}~{srpR.Max}/{srpR.Step}");
int CntI(GridSearchRunner.ParamRange r) => (r.Max - r.Min) / r.Step + 1;
int longTotal = CntI(lwR) * CntI(rpR), shortTotal = CntI(slwR) * CntI(srpR);
Console.WriteLine($" 조합: Long {longTotal} + Short {shortTotal} = {longTotal + shortTotal}");
Console.WriteLine();
// ── 체크포인트 로드 (이어하기) — 죽어도 진척 보존 ──
int budget = cmdArgs.Length > 1 && int.TryParse(cmdArgs[1], out var bg) ? bg : 1000;
string longCkptPath = Ckpt("tune_long_ckpt.csv");
string shortCkptPath = Ckpt("tune_short_ckpt.csv");
var longCk = LoadCkpt(longCkptPath);
var shortCk = LoadCkpt(shortCkptPath);
Console.WriteLine($" 체크포인트: Long {longCk.Count}/{longTotal}, Short {shortCk.Count}/{shortTotal} | 이번 실행 예산 {budget}개");
var longCombos = new List<(int lw, int rp)>();
for (int lw = lwR.Min; lw <= lwR.Max; lw += lwR.Step)
for (int rp = rpR.Min; rp <= rpR.Max; rp += rpR.Step) longCombos.Add((lw, rp));
var shortCombos = new List<(int slw, int srp)>();
for (int slw = slwR.Min; slw <= slwR.Max; slw += slwR.Step)
for (int srp = srpR.Min; srp <= srpR.Max; srp += srpR.Step) shortCombos.Add((slw, srp));
var sw = Stopwatch.StartNew();
int remaining = budget, processed = 0;
var inv = CultureInfo.InvariantCulture;
// Long 탐색 (체크포인트에 없는 것만, 예산 한도 내)
foreach (var (lw, rp) in longCombos)
{
if (longCk.ContainsKey((lw, rp))) continue;
if (remaining <= 0) break;
var r = runner.RunWithReplacements(allCandles,
new() { ["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString() });
double learn = r.Trades.Where(t => t.Direction == "Long" && t.ExitTime.Year == learnYear).Sum(t => t.ProfitPercent);
double val = r.Trades.Where(t => t.Direction == "Long" && t.ExitTime.Year > learnYear).Sum(t => t.ProfitPercent);
longCk[(lw, rp)] = (learn, val);
File.AppendAllText(longCkptPath, $"{lw},{rp},{learn.ToString(inv)},{val.ToString(inv)}\n");
remaining--; processed++;
if (processed % 100 == 0) { GC.Collect(); Console.WriteLine($" [Long] 누적 {longCk.Count}/{longTotal} | 이번 {processed} | {sw.Elapsed.TotalSeconds:F0}s"); }
}
// Short 탐색 (Long 완료 후에만)
if (longCk.Count >= longTotal)
{
foreach (var (slw, srp) in shortCombos)
{
if (shortCk.ContainsKey((slw, srp))) continue;
if (remaining <= 0) break;
var r = runner.RunWithReplacements(allCandles,
new() { ["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString() });
double learn = r.Trades.Where(t => t.Direction == "Short" && t.ExitTime.Year == learnYear).Sum(t => t.ProfitPercent);
double val = r.Trades.Where(t => t.Direction == "Short" && t.ExitTime.Year > learnYear).Sum(t => t.ProfitPercent);
shortCk[(slw, srp)] = (learn, val);
File.AppendAllText(shortCkptPath, $"{slw},{srp},{learn.ToString(inv)},{val.ToString(inv)}\n");
remaining--; processed++;
if (processed % 100 == 0) { GC.Collect(); Console.WriteLine($" [Short] 누적 {shortCk.Count}/{shortTotal} | 이번 {processed} | {sw.Elapsed.TotalSeconds:F0}s"); }
}
}
sw.Stop();
bool complete = longCk.Count >= longTotal && shortCk.Count >= shortTotal;
Console.WriteLine($"[이번 실행] 신규 {processed}개 | {sw.Elapsed.TotalSeconds:F0}s | 누적 Long {longCk.Count}/{longTotal} Short {shortCk.Count}/{shortTotal}");
if (!complete)
{
Console.WriteLine("TUNE_INCOMPLETE (재실행하면 이어서 진행)");
Log.CloseAndFlush();
return;
}
Console.WriteLine("TUNE_ALL_DONE — 집계 시작");
var longRes = longCk.Select(kv => (lw: kv.Key.Item1, rp: kv.Key.Item2, learn: kv.Value.Item1, val: kv.Value.Item2)).ToList();
var shortRes = shortCk.Select(kv => (slw: kv.Key.Item1, srp: kv.Key.Item2, learn: kv.Value.Item1, val: kv.Value.Item2)).ToList();
// 학습 기준 최적
var bL = longRes.OrderByDescending(x => x.learn).First();
var bS = shortRes.OrderByDescending(x => x.learn).First();
// 안정성 (주변 ±1스텝 학습수익 평균이 봉우리의 70% 이상이면 '높음')
double NeighL = longRes.Where(x => Math.Abs(x.lw - bL.lw) <= lwR.Step && Math.Abs(x.rp - bL.rp) <= rpR.Step
&& !(x.lw == bL.lw && x.rp == bL.rp)).Select(x => x.learn).DefaultIfEmpty(double.NaN).Average();
double NeighS = shortRes.Where(x => Math.Abs(x.slw - bS.slw) <= slwR.Step && Math.Abs(x.srp - bS.srp) <= srpR.Step
&& !(x.slw == bS.slw && x.srp == bS.srp)).Select(x => x.learn).DefaultIfEmpty(double.NaN).Average();
string StabLabel(double neigh, double peak) =>
double.IsNaN(neigh) ? "-" : (neigh >= peak * 0.7 ? "높음(고원)" : "낮음(뾰족)");
// 과최적화 판정 (학습 1등이 검증 순위 상위 25%면 통과)
string Verdict(List<(int a, int b, double learn, double val)> res, int a, int b, double valPnl)
{
int rank = res.OrderByDescending(x => x.val).ToList().FindIndex(x => x.a == a && x.b == b) + 1;
double pct = (double)rank / res.Count * 100;
if (valPnl <= 0) return $"⚠ 경고(검증 {valPnl:F1}% 손실)";
return pct <= 25 ? $"✅ 통과(검증 상위 {pct:F0}%)" : $"△ 부분통과(검증 +지만 {pct:F0}%)";
}
var longResAb = longRes.Select(x => (x.lw, x.rp, x.learn, x.val)).ToList();
var shortResAb = shortRes.Select(x => (x.slw, x.srp, x.learn, x.val)).ToList();
string vL = Verdict(longResAb, bL.lw, bL.rp, bL.val);
string vS = Verdict(shortResAb, bS.slw, bS.srp, bS.val);
// ── 콘솔 요약 ──
Console.WriteLine();
Console.WriteLine($"[완료] 탐색 소요: {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine();
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine($" 최종 권장 (학습={learnYear}) — {targetExchange}/{targetProductType}/{targetSymbol}");
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine($" Long : LW={bL.lw} RP={bL.rp} 학습 {bL.learn:F2}% → 검증 {bL.val:F2}% {StabLabel(NeighL, bL.learn)} {vL}");
Console.WriteLine($" Short: SLW={bS.slw} SRP={bS.srp} 학습 {bS.learn:F2}% → 검증 {bS.val:F2}% {StabLabel(NeighS, bS.learn)} {vS}");
Console.WriteLine($" 합계 : 학습 {bL.learn + bS.learn:F2}% → 검증 {bL.val + bS.val:F2}%");
Console.WriteLine();
// ── 최종 조합 1회 실행 (월별/연도별/거래내역용) ──
var fin = runner.RunWithReplacements(allCandles, new() {
["lengthWindow"] = bL.lw.ToString(), ["rangePeriod"] = bL.rp.ToString(),
["shortLengthWindow"] = bS.slw.ToString(), ["shortRangePeriod"] = bS.srp.ToString() });
string label = $"{targetExchange}/{targetProductType}/{targetSymbol}";
WriteTuneExcel(fin, longRes, shortRes, bL, bS, NeighL, NeighS, vL, vS,
learnYear, label, dataFrom, dataTo, "tune.xlsx");
// 거래내역 (진입시 임계값 + 구름대/72선 포함)
try
{
string pt = ExcelExporter.ExportTrades(fin,
Out("tune_best_trades.xlsx"),
label + " [tune 권장]", dataFrom, dataTo,
$"LW={bL.lw} RP={bL.rp} SLW={bS.slw} SRP={bS.srp} (학습={learnYear})");
Console.WriteLine($"[Excel] 거래내역: {pt}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 거래내역 저장 실패: {ex.Message}"); }
// 완료 플래그 (래퍼가 이걸 보고 반복 종료)
File.WriteAllText(Ckpt("tune_done.flag"), "done");
Console.WriteLine("TUNE_FLAG_WRITTEN");
Log.CloseAndFlush();
}
// tune 분석 결과 Excel (5시트)
void WriteTuneExcel(
ProfitCalculator.AnalysisResult fin,
List<(int lw, int rp, double learn, double val)> longRes,
List<(int slw, int srp, double learn, double val)> shortRes,
(int lw, int rp, double learn, double val) bL,
(int slw, int srp, double learn, double val) bS,
double neighL, double neighS, string vL, string vS,
int learnYear, string label, string dataFrom, string dataTo, string outFile)
{
try
{
string outPath = Out(outFile);
using var wb = new XLWorkbook();
// [1] 요약
var ws = wb.Worksheets.Add("요약");
ws.Cell("A1").Value = "대상"; ws.Cell("B1").Value = label;
ws.Cell("A2").Value = "기간"; ws.Cell("B2").Value = $"{dataFrom} ~ {dataTo}";
ws.Cell("A3").Value = "학습연도"; ws.Cell("B3").Value = learnYear;
ws.Range("A1:A3").Style.Font.Bold = true;
string[] sh = { "구분", "권장값", "학습%", "검증%", "안정성", "판정" };
for (int c = 0; c < sh.Length; c++) ws.Cell(5, c + 1).Value = sh[c];
ws.Range(5, 1, 5, sh.Length).Style.Font.Bold = true;
ws.Range(5, 1, 5, sh.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
ws.Range(5, 1, 5, sh.Length).Style.Font.FontColor = XLColor.White;
ws.Cell(6, 1).Value = "Long"; ws.Cell(6, 2).Value = $"LW={bL.lw} RP={bL.rp}";
ws.Cell(6, 3).Value = Math.Round(bL.learn, 2); ws.Cell(6, 4).Value = Math.Round(bL.val, 2);
ws.Cell(6, 5).Value = double.IsNaN(neighL) ? "-" : (neighL >= bL.learn * 0.7 ? "높음" : "낮음"); ws.Cell(6, 6).Value = vL;
ws.Cell(7, 1).Value = "Short"; ws.Cell(7, 2).Value = $"SLW={bS.slw} SRP={bS.srp}";
ws.Cell(7, 3).Value = Math.Round(bS.learn, 2); ws.Cell(7, 4).Value = Math.Round(bS.val, 2);
ws.Cell(7, 5).Value = double.IsNaN(neighS) ? "-" : (neighS >= bS.learn * 0.7 ? "높음" : "낮음"); ws.Cell(7, 6).Value = vS;
ws.Cell(8, 1).Value = "합계"; ws.Cell(8, 3).Value = Math.Round(bL.learn + bS.learn, 2); ws.Cell(8, 4).Value = Math.Round(bL.val + bS.val, 2);
ws.Range(8, 1, 8, 6).Style.Font.Bold = true;
ws.Columns().AdjustToContents();
// [2][3] 탐색 Top
void TopSheet(string name, IEnumerable<(string param, double learn, double val)> rows)
{
var w = wb.Worksheets.Add(name);
string[] h = { "순위", "파라미터", "학습%", "검증%" };
for (int c = 0; c < h.Length; c++) w.Cell(1, c + 1).Value = h[c];
w.Range(1, 1, 1, h.Length).Style.Font.Bold = true;
w.Range(1, 1, 1, h.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
w.Range(1, 1, 1, h.Length).Style.Font.FontColor = XLColor.White;
int rr = 2;
foreach (var x in rows.Take(20))
{
w.Cell(rr, 1).Value = rr - 1; w.Cell(rr, 2).Value = x.param;
w.Cell(rr, 3).Value = Math.Round(x.learn, 2); w.Cell(rr, 4).Value = Math.Round(x.val, 2);
rr++;
}
w.Columns().AdjustToContents(); w.SheetView.FreezeRows(1);
}
TopSheet("Long_Top", longRes.OrderByDescending(x => x.learn).Select(x => ($"LW={x.lw} RP={x.rp}", x.learn, x.val)));
TopSheet("Short_Top", shortRes.OrderByDescending(x => x.learn).Select(x => ($"SLW={x.slw} SRP={x.srp}", x.learn, x.val)));
// [4] 최적_월별
var wm = wb.Worksheets.Add("최적_월별");
string[] mh = { "월", "Long%", "Short%", "합계%", "구분" };
for (int c = 0; c < mh.Length; c++) wm.Cell(1, c + 1).Value = mh[c];
wm.Range(1, 1, 1, mh.Length).Style.Font.Bold = true;
wm.Range(1, 1, 1, mh.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
wm.Range(1, 1, 1, mh.Length).Style.Font.FontColor = XLColor.White;
var byMonth = fin.Trades.GroupBy(t => (t.ExitTime.Year, t.ExitTime.Month))
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month);
int mr = 2;
foreach (var g in byMonth)
{
double lp = g.Where(t => t.Direction == "Long").Sum(t => t.ProfitPercent);
double sp = g.Where(t => t.Direction == "Short").Sum(t => t.ProfitPercent);
wm.Cell(mr, 1).Value = $"{g.Key.Year}-{g.Key.Month:D2}";
wm.Cell(mr, 2).Value = Math.Round(lp, 2); wm.Cell(mr, 3).Value = Math.Round(sp, 2);
wm.Cell(mr, 4).Value = Math.Round(lp + sp, 2);
wm.Cell(mr, 5).Value = g.Key.Year == learnYear ? "학습" : "검증";
mr++;
}
wm.Columns().AdjustToContents(); wm.SheetView.FreezeRows(1);
// [5] 최적_연도별
var wy = wb.Worksheets.Add("최적_연도별");
string[] yh = { "연도", "Long%", "Short%", "합계%", "거래", "승", "패", "승률%", "구분" };
for (int c = 0; c < yh.Length; c++) wy.Cell(1, c + 1).Value = yh[c];
wy.Range(1, 1, 1, yh.Length).Style.Font.Bold = true;
wy.Range(1, 1, 1, yh.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
wy.Range(1, 1, 1, yh.Length).Style.Font.FontColor = XLColor.White;
int yr = 2;
foreach (var g in fin.Trades.GroupBy(t => t.ExitTime.Year).OrderBy(g => g.Key))
{
double lp = g.Where(t => t.Direction == "Long").Sum(t => t.ProfitPercent);
double sp = g.Where(t => t.Direction == "Short").Sum(t => t.ProfitPercent);
int wins = g.Count(t => t.ProfitPercent > 0), tot = g.Count();
wy.Cell(yr, 1).Value = g.Key; wy.Cell(yr, 2).Value = Math.Round(lp, 2);
wy.Cell(yr, 3).Value = Math.Round(sp, 2); wy.Cell(yr, 4).Value = Math.Round(lp + sp, 2);
wy.Cell(yr, 5).Value = tot; wy.Cell(yr, 6).Value = wins; wy.Cell(yr, 7).Value = tot - wins;
wy.Cell(yr, 8).Value = tot > 0 ? Math.Round((double)wins / tot * 100, 1) : 0;
wy.Cell(yr, 9).Value = g.Key == learnYear ? "학습" : "검증";
yr++;
}
wy.Columns().AdjustToContents();
wb.SaveAs(outPath);
Console.WriteLine($"[Excel] 분석: {Path.GetFullPath(outPath)}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] tune.xlsx 저장 실패: {ex.Message}"); }
}
// ════════════════════════════════════════════════════════════════
// [커맨드 10] entrystats — 진입시 구름대/72선/24선 조합별 승률·수익
// ════════════════════════════════════════════════════════════════
void RunEntryStats(string[] cmdArgs)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
// 기본: 권장 조합 (Long LW=8 RP=66 / Short SLW=6 SRP=26)
int lw = cmdArgs.Length > 1 ? int.Parse(cmdArgs[1]) : 8;
int rp = cmdArgs.Length > 2 ? int.Parse(cmdArgs[2]) : 66;
int slw = cmdArgs.Length > 3 ? int.Parse(cmdArgs[3]) : 6;
int srp = cmdArgs.Length > 4 ? int.Parse(cmdArgs[4]) : 26;
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — 진입시 구름대/72선/24선 조합별 통계 ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine($" 파라미터: LW={lw} RP={rp} SLW={slw} SRP={srp}");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
var runner = new GridSearchRunner(scriptPath, warmupBars);
var res = runner.RunWithReplacements(allCandles, new() {
["lengthWindow"] = lw.ToString(), ["rangePeriod"] = rp.ToString(),
["shortLengthWindow"] = slw.ToString(), ["shortRangePeriod"] = srp.ToString() });
// 진입가 기준 상태 분류
string Cloud(ProfitCalculator.TradeResult t)
{
if (!t.EntryCloudTop.HasValue || !t.EntryCloudBottom.HasValue) return "na";
double e = (double)t.EntryPrice;
return e > t.EntryCloudTop.Value ? "위" : e < t.EntryCloudBottom.Value ? "아래" : "구름안";
}
string S72(ProfitCalculator.TradeResult t) =>
!t.EntrySma72.HasValue ? "na" : ((double)t.EntryPrice >= t.EntrySma72.Value ? "위" : "아래");
string S24(ProfitCalculator.TradeResult t) =>
!t.EntrySma24.HasValue ? "na" : ((double)t.EntryPrice >= t.EntrySma24.Value ? "위" : "아래");
var rows = new List<(string dir, string cloud, string s72, string s24, int n, int w, double tot, double avg)>();
foreach (var dir in new[] { "Long", "Short" })
{
var dirTrades = res.Trades.Where(t => t.Direction == dir).ToList();
Console.WriteLine($"── {dir} (총 {dirTrades.Count}건) ──");
Console.WriteLine($" {"구름대",-6} {"72선",-5} {"24선",-5} {"거래",5} {"승",4} {"패",4} {"승률%",7} {"총수익%",9} {"평균%",7}");
Console.WriteLine($" {new string('─', 62)}");
var groups = dirTrades
.GroupBy(t => (c: Cloud(t), s72: S72(t), s24: S24(t)))
.Select(g => (g.Key.c, g.Key.s72, g.Key.s24,
n: g.Count(),
w: g.Count(t => t.ProfitPercent > 0),
tot: g.Sum(t => t.ProfitPercent),
avg: g.Average(t => t.ProfitPercent)))
.OrderByDescending(x => x.n)
.ToList();
foreach (var g in groups)
{
double wr = g.n > 0 ? (double)g.w / g.n * 100 : 0;
Console.WriteLine($" {g.c,-6} {g.s72,-5} {g.s24,-5} {g.n,5} {g.w,4} {g.n - g.w,4} {wr,7:F1} {g.tot,9:F2} {g.avg,7:F2}");
rows.Add((dir, g.c, g.s72, g.s24, g.n, g.w, g.tot, g.avg));
}
// 합계
int tn = dirTrades.Count, tw = dirTrades.Count(t => t.ProfitPercent > 0);
double ttot = dirTrades.Sum(t => t.ProfitPercent);
Console.WriteLine($" {"[합계]",-6} {"",-5} {"",-5} {tn,5} {tw,4} {tn - tw,4} {(tn > 0 ? (double)tw / tn * 100 : 0),7:F1} {ttot,9:F2} {(tn > 0 ? ttot / tn : 0),7:F2}");
Console.WriteLine();
}
// Excel 출력
try
{
string outPath = Out("entrystats.xlsx");
using var wb = new XLWorkbook();
foreach (var dir in new[] { "Long", "Short" })
{
var ws = wb.Worksheets.Add(dir);
string[] h = { "구름대", "72선", "24선", "거래", "승", "패", "승률%", "총수익%", "평균수익%" };
for (int c = 0; c < h.Length; c++) ws.Cell(1, c + 1).Value = h[c];
ws.Range(1, 1, 1, h.Length).Style.Font.Bold = true;
ws.Range(1, 1, 1, h.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
ws.Range(1, 1, 1, h.Length).Style.Font.FontColor = XLColor.White;
int rr = 2;
foreach (var g in rows.Where(x => x.dir == dir))
{
double wr = g.n > 0 ? (double)g.w / g.n * 100 : 0;
ws.Cell(rr, 1).Value = g.cloud; ws.Cell(rr, 2).Value = g.s72; ws.Cell(rr, 3).Value = g.s24;
ws.Cell(rr, 4).Value = g.n; ws.Cell(rr, 5).Value = g.w; ws.Cell(rr, 6).Value = g.n - g.w;
ws.Cell(rr, 7).Value = Math.Round(wr, 1);
var ct = ws.Cell(rr, 8); ct.Value = Math.Round(g.tot, 2);
ct.Style.Font.FontColor = g.tot >= 0 ? XLColor.Green : XLColor.Red;
ws.Cell(rr, 9).Value = Math.Round(g.avg, 2);
rr++;
}
ws.Columns().AdjustToContents(); ws.SheetView.FreezeRows(1);
}
wb.SaveAs(outPath);
Console.WriteLine($"[Excel] 저장: {Path.GetFullPath(outPath)}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 저장 실패: {ex.Message}"); }
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 11] tunecf — 거친→정밀 2단계 (tune Step=1과 비교용)
// ════════════════════════════════════════════════════════════════
void RunTuneCf()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — tunecf (거친→정밀 2단계) ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
var paramSection = section.GetSection("Parameters");
var lwR = LoadRange(paramSection, "lengthWindow");
var rpR = LoadRange(paramSection, "rangePeriod");
var slwR = LoadRange(paramSection, "shortLengthWindow");
var srpR = LoadRange(paramSection, "shortRangePeriod");
var runner = new GridSearchRunner(scriptPath, warmupBars);
int learnYear = allCandles.Min(c => c.Time.Year);
// (a,b) 쌍을 주어진 범위로 탐색 → (a,b,학습,검증)
List<(int a, int b, double learn, double val)> Search(
string keyA, string keyB, int aMin, int aMax, int aStep,
int bMin, int bMax, int bStep, string dir)
{
var res = new List<(int, int, double, double)>();
for (int a = aMin; a <= aMax; a += aStep)
for (int b = bMin; b <= bMax; b += bStep)
{
var r = runner.RunWithReplacements(allCandles,
new() { [keyA] = a.ToString(), [keyB] = b.ToString() });
double learn = r.Trades.Where(t => t.Direction == dir && t.ExitTime.Year == learnYear).Sum(t => t.ProfitPercent);
double val = r.Trades.Where(t => t.Direction == dir && t.ExitTime.Year > learnYear).Sum(t => t.ProfitPercent);
res.Add((a, b, learn, val));
}
return res;
}
int Clamp(int v, int lo, int hi) => Math.Max(lo, Math.Min(hi, v));
var sw = Stopwatch.StartNew();
// 거친 스텝
int lwCs = 4, rpCs = 30, slwCs = 2, srpCs = 10;
// ── 1단계: 거친 탐색 ──
Console.WriteLine("[1단계 거친] Long...");
var lc = Search("lengthWindow", "rangePeriod", lwR.Min, lwR.Max, lwCs, rpR.Min, rpR.Max, rpCs, "Long");
var cL = lc.OrderByDescending(x => x.learn).First();
Console.WriteLine($" 거친 Long 1등: LW={cL.a} RP={cL.b} (학습 {cL.learn:F2}%)");
Console.WriteLine("[1단계 거친] Short...");
var sc = Search("shortLengthWindow", "shortRangePeriod", slwR.Min, slwR.Max, slwCs, srpR.Min, srpR.Max, srpCs, "Short");
var cS = sc.OrderByDescending(x => x.learn).First();
Console.WriteLine($" 거친 Short 1등: SLW={cS.a} SRP={cS.b} (학습 {cS.learn:F2}%)");
// ── 2단계: 정밀 탐색 (1등 ±거친스텝, Step=1) ──
Console.WriteLine("[2단계 정밀] Long 주변...");
var lf = Search("lengthWindow", "rangePeriod",
Clamp(cL.a - lwCs, lwR.Min, lwR.Max), Clamp(cL.a + lwCs, lwR.Min, lwR.Max), 1,
Clamp(cL.b - rpCs, rpR.Min, rpR.Max), Clamp(cL.b + rpCs, rpR.Min, rpR.Max), 1, "Long");
Console.WriteLine("[2단계 정밀] Short 주변...");
var sf = Search("shortLengthWindow", "shortRangePeriod",
Clamp(cS.a - slwCs, slwR.Min, slwR.Max), Clamp(cS.a + slwCs, slwR.Min, slwR.Max), 1,
Clamp(cS.b - srpCs, srpR.Min, srpR.Max), Clamp(cS.b + srpCs, srpR.Min, srpR.Max), 1, "Short");
sw.Stop();
var bL = lf.OrderByDescending(x => x.learn).First();
var bS = sf.OrderByDescending(x => x.learn).First();
int totalCombos = lc.Count + sc.Count + lf.Count + sf.Count;
// 안정성/판정 (정밀 결과 기준, ±1)
double NeighL = lf.Where(x => Math.Abs(x.a - bL.a) <= 1 && Math.Abs(x.b - bL.b) <= 1 && !(x.a == bL.a && x.b == bL.b)).Select(x => x.learn).DefaultIfEmpty(double.NaN).Average();
double NeighS = sf.Where(x => Math.Abs(x.a - bS.a) <= 1 && Math.Abs(x.b - bS.b) <= 1 && !(x.a == bS.a && x.b == bS.b)).Select(x => x.learn).DefaultIfEmpty(double.NaN).Average();
string Verdict(List<(int a, int b, double learn, double val)> res, int a, int b, double valPnl)
{
int rank = res.OrderByDescending(x => x.val).ToList().FindIndex(x => x.a == a && x.b == b) + 1;
double pct = (double)rank / res.Count * 100;
if (valPnl <= 0) return $"⚠ 경고(검증 {valPnl:F1}% 손실)";
return pct <= 25 ? $"✅ 통과(검증 상위 {pct:F0}%)" : $"△ 부분통과({pct:F0}%)";
}
string vL = Verdict(lf, bL.a, bL.b, bL.val);
string vS = Verdict(sf, bS.a, bS.b, bS.val);
Console.WriteLine();
Console.WriteLine($"[완료] {totalCombos}조합 / {sw.Elapsed.TotalSeconds:F1}초");
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine($" tunecf 최종 권장 (학습={learnYear})");
Console.WriteLine($" Long : LW={bL.a} RP={bL.b} 학습 {bL.learn:F2}% → 검증 {bL.val:F2}% {vL}");
Console.WriteLine($" Short: SLW={bS.a} SRP={bS.b} 학습 {bS.learn:F2}% → 검증 {bS.val:F2}% {vS}");
Console.WriteLine($" 합계 : 학습 {bL.learn + bS.learn:F2}% → 검증 {bL.val + bS.val:F2}%");
Console.WriteLine();
var fin = runner.RunWithReplacements(allCandles, new() {
["lengthWindow"] = bL.a.ToString(), ["rangePeriod"] = bL.b.ToString(),
["shortLengthWindow"] = bS.a.ToString(), ["shortRangePeriod"] = bS.b.ToString() });
string label = $"{targetExchange}/{targetProductType}/{targetSymbol}";
WriteTuneExcel(fin, lf, sf, (bL.a, bL.b, bL.learn, bL.val), (bS.a, bS.b, bS.learn, bS.val),
NeighL, NeighS, vL, vS, learnYear, label, dataFrom, dataTo, "tune_cf.xlsx");
try
{
string pt = ExcelExporter.ExportTrades(fin, Out("tune_cf_best_trades.xlsx"),
label + " [tunecf 권장]", dataFrom, dataTo,
$"LW={bL.a} RP={bL.b} SLW={bS.a} SRP={bS.b} (거친→정밀, 학습={learnYear})");
Console.WriteLine($"[Excel] 거래내역: {pt}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 거래내역 실패: {ex.Message}"); }
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// [커맨드 12] bsweep — B안 고정임계값 스윕 (Long/Short 각각 0.1~4.0 따로)
// ════════════════════════════════════════════════════════════════
void RunBSweep()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}")
.CreateLogger();
Console.WriteLine("╔══════════════════════════════════════════════════════╗");
Console.WriteLine("║ Analysis — B안 고정임계값 스윕 (Long/Short 따로) ║");
Console.WriteLine("╚══════════════════════════════════════════════════════╝");
Console.WriteLine($" 대상: {targetSymbol} | 임계값 범위 0.1~4.0 (윈도우는 무의미)");
Console.WriteLine();
var (allCandles, dataFrom, dataTo) = LoadTargetCandles();
if (allCandles == null) return;
var fixedSection = section.GetSection("FixedThresholds");
var tlRange = LoadRangeF(fixedSection, "rangeThresholdLong");
var tsRange = LoadRangeF(fixedSection, "rangeThresholdShort");
string fixedScriptPath = Path.Combine(Path.GetDirectoryName(scriptPath) ?? ".", "example_fixed.pine");
var runnerB = new GridSearchRunner(fixedScriptPath, warmupBars);
int learnYear = allCandles.Min(c => c.Time.Year);
(double total, double learn, double val, int n, int w) Stat(IEnumerable<ProfitCalculator.TradeResult> trades)
{
var ts = trades.ToList();
return (ts.Sum(t => t.ProfitPercent),
ts.Where(t => t.ExitTime.Year == learnYear).Sum(t => t.ProfitPercent),
ts.Where(t => t.ExitTime.Year > learnYear).Sum(t => t.ProfitPercent),
ts.Count, ts.Count(t => t.ProfitPercent > 0));
}
var longRows = new List<(double th, double total, double learn, double val, int n, int w)>();
var shortRows = new List<(double th, double total, double learn, double val, int n, int w)>();
void Sweep(string side, string key, (double, double, double) range, string dir,
List<(double, double, double, double, int, int)> rows)
{
Console.WriteLine($"── {side} 스윕 ({key} 0.1~4.0) ──");
Console.WriteLine($" {"임계값",6} {"총%",9} {"학습%",9} {"검증%",9} {"거래",6} {"승률%",7}");
Console.WriteLine($" {new string('─', 50)}");
foreach (double th in EnumerateF(range))
{
var r = runnerB.RunWithReplacements(allCandles,
new() { [key] = th.ToString("0.####", CultureInfo.InvariantCulture) });
var (total, learn, val, n, w) = Stat(r.Trades.Where(t => t.Direction == dir));
rows.Add((th, total, learn, val, n, w));
double wr = n > 0 ? (double)w / n * 100 : 0;
Console.WriteLine($" {th,6:F1} {total,9:F2} {learn,9:F2} {val,9:F2} {n,6} {wr,7:F1}");
}
var best = rows.OrderByDescending(x => x.Item2).First();
Console.WriteLine($" ▶ {side} 최고: 임계값 {best.Item1:F1} (총 {best.Item2:F2}%, 학습 {best.Item3:F2}%, 검증 {best.Item4:F2}%)");
Console.WriteLine();
}
var sw = Stopwatch.StartNew();
Sweep("Long", "rangeThresholdLongPercent", tlRange, "Long", longRows);
Sweep("Short", "rangeThresholdShortPercent", tsRange, "Short", shortRows);
sw.Stop();
Console.WriteLine($"[완료] {sw.Elapsed.TotalSeconds:F1}초");
// Excel (Long/Short 시트)
try
{
string outPath = Out("bsweep.xlsx");
using var wb = new XLWorkbook();
void WriteSheet(string name, List<(double th, double total, double learn, double val, int n, int w)> rows)
{
var ws = wb.Worksheets.Add(name);
string[] h = { "임계값%", "총수익%", "학습%(" + learnYear + ")", "검증%", "거래", "승", "승률%" };
for (int c = 0; c < h.Length; c++) ws.Cell(1, c + 1).Value = h[c];
ws.Range(1, 1, 1, h.Length).Style.Font.Bold = true;
ws.Range(1, 1, 1, h.Length).Style.Fill.BackgroundColor = XLColor.FromArgb(68, 114, 196);
ws.Range(1, 1, 1, h.Length).Style.Font.FontColor = XLColor.White;
int rr = 2;
double best = rows.Max(x => x.total);
foreach (var x in rows)
{
ws.Cell(rr, 1).Value = Math.Round(x.th, 2);
var ct = ws.Cell(rr, 2); ct.Value = Math.Round(x.total, 2);
ct.Style.Font.FontColor = x.total >= 0 ? XLColor.Green : XLColor.Red;
if (x.total == best) { ws.Range(rr, 1, rr, h.Length).Style.Fill.BackgroundColor = XLColor.LightYellow; ct.Style.Font.Bold = true; }
ws.Cell(rr, 3).Value = Math.Round(x.learn, 2);
ws.Cell(rr, 4).Value = Math.Round(x.val, 2);
ws.Cell(rr, 5).Value = x.n; ws.Cell(rr, 6).Value = x.w;
ws.Cell(rr, 7).Value = x.n > 0 ? Math.Round((double)x.w / x.n * 100, 1) : 0;
rr++;
}
ws.Columns().AdjustToContents(); ws.SheetView.FreezeRows(1);
}
WriteSheet("Long", longRows);
WriteSheet("Short", shortRows);
wb.SaveAs(outPath);
Console.WriteLine($"[Excel] 저장: {Path.GetFullPath(outPath)}");
}
catch (Exception ex) { Console.WriteLine($"[Excel] 저장 실패: {ex.Message}"); }
Log.CloseAndFlush();
}
// ════════════════════════════════════════════════════════════════
// 공통: DB 캔들 로드
// ════════════════════════════════════════════════════════════════
(List<CandleData>? candles, string dataFrom, string dataTo) LoadTargetCandles()
{
var allDbFiles = CandleDbReader.FindAllDbFiles(dbFolder);
Console.WriteLine($"[DB] {dbFolder} 폴더에서 총 {allDbFiles.Count}개 DB 파일 발견");
foreach (var group in allDbFiles.GroupBy(f => $"{f.Exchange}/{f.ProductType}/{f.Symbol}"))
{
Console.WriteLine($" {group.Key}/");
foreach (var f in group)
Console.WriteLine($" └ {f.FileName}");
}
var targetDbFiles = CandleDbReader.Filter(allDbFiles, targetExchange, targetProductType, targetSymbol);
if (targetDbFiles.Count == 0)
{
Console.WriteLine($"[오류] 대상 종목 DB 파일을 찾을 수 없습니다: " +
$"{targetExchange}/{targetProductType}/{targetSymbol}");
return (null, "", "");
}
Console.WriteLine();
Console.WriteLine($"[대상] {targetExchange}/{targetProductType}/{targetSymbol} " +
$"— {targetDbFiles.Count}개 DB 파일");
var allCandles = new List<CandleData>();
foreach (var dbInfo in targetDbFiles)
{
var candles = CandleDbReader.LoadAll(dbInfo.FilePath);
if (candles.Count > 0)
{
Console.WriteLine($" → {dbInfo.FileName}: {candles.Count}개 캔들 " +
$"({candles.First().Time:yyyy-MM-dd} ~ {candles.Last().Time:yyyy-MM-dd})");
}
else
{
Console.WriteLine($" → {dbInfo.FileName}: 캔들 없음");
}
allCandles.AddRange(candles);
}
allCandles = allCandles.OrderBy(c => c.Time).ToList();
string dataFrom = allCandles.First().Time.ToString("yyyy-MM-dd HH:mm");
string dataTo = allCandles.Last().Time.ToString("yyyy-MM-dd HH:mm");
Console.WriteLine($"[DB] 총 캔들: {allCandles.Count}개 | 기간: {dataFrom} ~ {dataTo}");
Console.WriteLine();
return (allCandles, dataFrom, dataTo);
}
// ════════════════════════════════════════════════════════════════
// 공통: 결과 상세 출력 (연간/월별)
// ════════════════════════════════════════════════════════════════
void PrintDetailedResult(ProfitCalculator.AnalysisResult r, int rank, bool showTrades = false)
{
int trades = r.Trades.Count;
int wins = r.Trades.Count(t => t.ProfitPercent > 0);
int loses = trades - wins;
double winRate = trades > 0 ? (double)wins / trades * 100 : 0;
string header = rank > 0
? $"── #{rank} LW={r.LengthWindow} RP={r.RangePeriod} SLW={r.ShortLengthWindow} SRP={r.ShortRangePeriod} ──"
: $"── LW={r.LengthWindow} RP={r.RangePeriod} SLW={r.ShortLengthWindow} SRP={r.ShortRangePeriod} ──";
Console.WriteLine(header);
Console.WriteLine($" 총 수익률: {r.TotalProfitPercent:F2}% | 거래: {trades}건 (승 {wins} / 패 {loses}) | 승률: {winRate:F1}%");
Console.WriteLine();
// ── 연간 수익률 ──
if (r.YearlyProfits.Count > 0)
{
Console.WriteLine($" [ 연간 수익률 ]");
Console.WriteLine($" {"년도",6} {"수익률%",10} {"거래",6} {"승",4} {"패",4} {"승률%",7}");
Console.WriteLine($" {new string('─', 43)}");
foreach (var y in r.YearlyProfits)
{
double wr = y.TradeCount > 0 ? (double)y.WinCount / y.TradeCount * 100 : 0;
Console.WriteLine($" {y.Year,6} {y.TotalProfitPercent,10:F2} {y.TradeCount,6} {y.WinCount,4} {y.LoseCount,4} {wr,7:F1}");
}
Console.WriteLine();
}
// ── 월별 수익률 ──
if (r.MonthlyProfits.Count > 0)
{
Console.WriteLine($" [ 월별 수익률 ]");
Console.WriteLine($" {"년-월",8} {"수익률%",10} {"거래",6} {"승",4} {"패",4} {"승률%",7}");
Console.WriteLine($" {new string('─', 45)}");
foreach (var m in r.MonthlyProfits)
{
double wr = m.TradeCount > 0 ? (double)m.WinCount / m.TradeCount * 100 : 0;
Console.WriteLine($" {m.Year}-{m.Month:D2} {m.TotalProfitPercent,10:F2} {m.TradeCount,6} {m.WinCount,4} {m.LoseCount,4} {wr,7:F1}");
}
Console.WriteLine();
}
// ── 개별 거래 (test 모드) ──
if (showTrades && r.Trades.Count > 0)
{
Console.WriteLine($" [ 개별 거래 내역 ]");
Console.WriteLine($" {"#",4} {"방향",6} {"진입시간",20} {"청산시간",20} {"진입가",12} {"청산가",12} {"수익%",8}");
Console.WriteLine($" {new string('─', 88)}");
for (int i = 0; i < r.Trades.Count; i++)
{
var t = r.Trades[i];
string dir = t.Direction == "Long" ? "Long " : "Short";
Console.WriteLine($" {i + 1,4} {dir,6} {t.EntryTime:yyyy-MM-dd HH:mm} {t.ExitTime:yyyy-MM-dd HH:mm} " +
$"{t.EntryPrice,12:F4} {t.ExitPrice,12:F4} {t.ProfitPercent,8:F2}");
}
Console.WriteLine();
}
Console.WriteLine();
}
// ════════════════════════════════════════════════════════════════
// 도움말
// ════════════════════════════════════════════════════════════════
void PrintUsage()
{
Console.WriteLine("Analysis — 트레이딩 전략 파라미터 분석 도구");
Console.WriteLine();
Console.WriteLine("사용법:");
Console.WriteLine(" Analysis grid 그리드 서치 실행 (기본)");
Console.WriteLine(" Analysis test [LW] [RP] [SLW] [SRP] 단일 파라미터 테스트");
Console.WriteLine(" Analysis import-csv <CSV경로> [타임프레임] TradingView CSV → .db 변환");
Console.WriteLine(" Analysis download [타임프레임] [최대일수] Bitget API에서 캔들 다운로드");
Console.WriteLine();
Console.WriteLine("예시:");
Console.WriteLine(" Analysis grid");
Console.WriteLine(" Analysis test 12 240 4 40");
Console.WriteLine(" Analysis import-csv \"C:\\Downloads\\XRPUSDT.csv\" 30m");
Console.WriteLine(" Analysis download 30m 62");
}
// ════════════════════════════════════════════════════════════════
// 헬퍼 메서드
// ════════════════════════════════════════════════════════════════
static GridSearchRunner.ParamRange LoadRange(IConfigurationSection section, string name)
{
var sub = section.GetSection(name);
int min = int.Parse(sub["Min"] ?? "1");
int max = int.Parse(sub["Max"] ?? "1");
int step = int.Parse(sub["Step"] ?? "1");
return new GridSearchRunner.ParamRange(min, max, step);
}
// 실수 범위 로드 (B안 임계값용)
static (double Min, double Max, double Step) LoadRangeF(IConfigurationSection section, string name)
{
var sub = section.GetSection(name);
double min = double.Parse(sub["Min"] ?? "0", CultureInfo.InvariantCulture);
double max = double.Parse(sub["Max"] ?? "0", CultureInfo.InvariantCulture);
double step = double.Parse(sub["Step"] ?? "1", CultureInfo.InvariantCulture);
return (min, max, step);
}
// 실수 범위 열거 (부동소수 누적 오차 방지: 정수 인덱스로 계산)
static IEnumerable<double> EnumerateF((double Min, double Max, double Step) r)
{
if (r.Step <= 0) { yield return r.Min; yield break; }
int count = (int)Math.Round((r.Max - r.Min) / r.Step);
for (int i = 0; i <= count; i++)
yield return Math.Round(r.Min + i * r.Step, 6);
}
// Pearson 상관계수 (NaN 쌍은 제외 = pairwise deletion). 유효표본 3 미만이면 NaN.
static double Pearson(double[] x, double[] y)
{
var xs = new List<double>(); var ys = new List<double>();
for (int i = 0; i < x.Length; i++)
if (!double.IsNaN(x[i]) && !double.IsNaN(y[i])) { xs.Add(x[i]); ys.Add(y[i]); }
int n = xs.Count;
if (n < 3) return double.NaN;
double mx = xs.Average(), my = ys.Average(), sxy = 0, sxx = 0, syy = 0;
for (int i = 0; i < n; i++)
{
double dx = xs[i] - mx, dy = ys[i] - my;
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
}
if (sxx <= 0 || syy <= 0) return double.NaN;
return sxy / Math.Sqrt(sxx * syy);
}
// 1칸 시프트 (result[i] = a[i-1], result[0] = NaN) — 시차 상관용
static double[] Shift(double[] a)
{
var r = new double[a.Length];
r[0] = double.NaN;
for (int i = 1; i < a.Length; i++) r[i] = a[i - 1];
return r;
}
// tune 체크포인트 로드: "a,b,learn,val" 줄 → Dict<(a,b),(learn,val)>
static Dictionary<(int, int), (double learn, double val)> LoadCkpt(string path)
{
var d = new Dictionary<(int, int), (double, double)>();
if (!File.Exists(path)) return d;
var inv = CultureInfo.InvariantCulture;
foreach (var line in File.ReadAllLines(path))
{
var p = line.Split(',');
if (p.Length < 4) continue;
if (int.TryParse(p[0], out int a) && int.TryParse(p[1], out int b)
&& double.TryParse(p[2], NumberStyles.Any, inv, out double l)
&& double.TryParse(p[3], NumberStyles.Any, inv, out double v))
d[(a, b)] = (l, v);
}
return d;
}
static void SaveResultsCsv(List<ProfitCalculator.AnalysisResult> results, string outputPath,
string targetLabel, string dataFrom, string dataTo)
{
string? dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var sb = new StringBuilder();
sb.AppendLine($"# Target: {targetLabel}");
sb.AppendLine($"# Period: {dataFrom} ~ {dataTo}");
sb.AppendLine();
sb.AppendLine("=== 파라미터별 총 수익률 ===");
sb.AppendLine("LengthWindow,RangePeriod,ShortLengthWindow,ShortRangePeriod,TotalProfitPct,TradeCount,WinCount,LoseCount,WinRate");
foreach (var r in results.OrderByDescending(r => r.TotalProfitPercent))
{
int trades = r.Trades.Count;
int wins = r.Trades.Count(t => t.ProfitPercent > 0);
int loses = r.Trades.Count(t => t.ProfitPercent <= 0);
double winRate = trades > 0 ? (double)wins / trades * 100 : 0;
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0},{1},{2},{3},{4:F4},{5},{6},{7},{8:F2}",
r.LengthWindow, r.RangePeriod,
r.ShortLengthWindow, r.ShortRangePeriod,
r.TotalProfitPercent, trades, wins, loses, winRate));
}
sb.AppendLine();
sb.AppendLine("=== 월간 수익률 상세 ===");
sb.AppendLine("LengthWindow,RangePeriod,ShortLengthWindow,ShortRangePeriod,Year,Month,MonthlyProfitPct,TradeCount,WinCount,LoseCount");
foreach (var r in results.OrderByDescending(r => r.TotalProfitPercent))
{
foreach (var m in r.MonthlyProfits)
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0},{1},{2},{3},{4},{5},{6:F4},{7},{8},{9}",
r.LengthWindow, r.RangePeriod,
r.ShortLengthWindow, r.ShortRangePeriod,
m.Year, m.Month,
m.TotalProfitPercent, m.TradeCount, m.WinCount, m.LoseCount));
}
}
sb.AppendLine();
sb.AppendLine("=== 연간 수익률 상세 ===");
sb.AppendLine("LengthWindow,RangePeriod,ShortLengthWindow,ShortRangePeriod,Year,YearlyProfitPct,TradeCount,WinCount,LoseCount");
foreach (var r in results.OrderByDescending(r => r.TotalProfitPercent))
{
foreach (var y in r.YearlyProfits)
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0},{1},{2},{3},{4},{5:F4},{6},{7},{8}",
r.LengthWindow, r.RangePeriod,
r.ShortLengthWindow, r.ShortRangePeriod,
y.Year, y.TotalProfitPercent,
y.TradeCount, y.WinCount, y.LoseCount));
}
}
File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8);
}