1760 lines
90 KiB
C#
1760 lines
90 KiB
C#
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);
|
||
}
|