using Buyer; using Trading.Core; using System.Security.Cryptography; using System.Text; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = AppContext.BaseDirectory }); builder.WebHost.UseUrls(builder.Configuration["Buyer:ListenUrl"] ?? "http://127.0.0.1:5081"); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); var token = Environment.GetEnvironmentVariable("BUYER_INTERNAL_TOKEN"); if (string.IsNullOrWhiteSpace(token) || token.Length < 32) throw new InvalidOperationException("BUYER_INTERNAL_TOKEN must contain at least 32 characters. See docs/구현시작.md"); var runtime = new ObservationRuntime(); var databasePath = Path.GetFullPath(builder.Configuration["Buyer:DatabasePath"] ?? Path.Combine(AppContext.BaseDirectory,"data","trading.db")); Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); // One writer/strategy worker per database. OS releases the lock after crashes. using var instanceLock = new FileStream(databasePath + ".lock",FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None); var accountsPath=Path.Combine(Path.GetDirectoryName(databasePath)!,"accounts"); Directory.CreateDirectory(accountsPath); using var accountsLock=new FileStream(Path.Combine(accountsPath,".owner.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None); var store = new TradingStore(databasePath); builder.Services.AddSingleton(store); builder.Services.AddSingleton(runtime); builder.Services.AddHostedService(); builder.Services.AddSingleton(_=>new BitgetVenue(new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(8)},Environment.GetEnvironmentVariable)); builder.Services.AddSingleton();builder.Services.AddHostedService(); builder.Services.AddSingleton(sp=>new AccountRegistry( accountsPath, builder.Configuration["Buyer:KeyRoot"]??Path.Combine(Path.GetDirectoryName(databasePath)!,".secrets","accounts"), store,sp.GetRequiredService(),credentials=>new BitgetVenue( new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(8)},credentials),sp.GetRequiredService().CredentialsConfigured)); var app = builder.Build(); app.MapAccountEndpoints(); app.Use(async (context,next) => { if(context.Request.Path != "/health" && !SecretMatch(context.Request.Headers["X-Buyer-Token"].ToString(),token)) {context.Response.StatusCode=401;return;} try { await next(context); } catch(ArgumentException ex){context.Response.StatusCode=400;await context.Response.WriteAsJsonAsync(new {error=ex.Message});} catch(InvalidOperationException ex){context.Response.StatusCode=409;await context.Response.WriteAsJsonAsync(new {error=ex.Message});} }); app.MapGet("/health",()=>new {service="Buyer",defaultMode="Observe",executionRequiresExplicitActivation=true}); app.MapGet("/api/status",()=>runtime.Snapshot()); app.MapGet("/api/settings",()=>store.GetSettings()); app.MapPut("/api/settings",(SettingsVersion update)=>update.Settings is null ? Results.BadRequest(new {error="settings is required"}) : Results.Ok(store.SaveSettings(update.Settings,update.Revision))); app.MapPost("/api/control/{command}",(string command)=> { if(command is not ("start" or "stop"))return Results.BadRequest(new {error="Only observation start/stop supported; no live orders."}); runtime.Requested=command=="start"; store.Audit("ObservationControl",command);return Results.Ok(new {accepted=true,mode="Observe",command}); }); app.MapGet("/api/signals",()=>store.Signals()); app.MapGet("/api/comparisons",()=>store.Compare(runtime.StrategyVersion,runtime.Symbol,runtime.Interval,TradingStore.Now())); app.MapGet("/api/audit",()=>store.AuditRows()); app.MapGet("/api/execution",(ExecutionEngine engine)=>engine.SnapshotAsync()); app.MapPost("/api/execution/mode",async(ModeRequest input,ExecutionEngine engine,CancellationToken ct)=>{await engine.SetModeAsync(input.Mode,input.Confirmation??"",ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/resume",async(ConfirmationRequest input,ExecutionEngine engine,CancellationToken ct)=>{await engine.ResumeAsync(input.Confirmation??"",ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/pause",async(ExecutionEngine engine,CancellationToken ct)=>{await engine.PauseAsync(ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/connect",async(ExecutionEngine engine,CancellationToken ct)=>{await engine.CheckConnectionAsync(ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/close",async(CloseRequest input,ExecutionEngine engine,CancellationToken ct)=>{await engine.CloseAsync(input.Direction,input.Confirmation??"",ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/cancel",async(CancelRequest input,ExecutionEngine engine,CancellationToken ct)=>{await engine.CancelAsync(input.Id,ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/execution/paper",async(PaperOptions input,ExecutionEngine engine,CancellationToken ct)=>{await engine.ConfigurePaperAsync(input,ct);return Results.Ok(new{accepted=true});}); app.MapPost("/api/policy/preview",(PolicyScenario scenario)=> { var version=store.GetSettings();var decision=PolicyEvaluator.Evaluate(version.Settings,scenario); store.Audit("PolicyPreview",System.Text.Json.JsonSerializer.Serialize(new {version.Revision,scenario,decision},JsonDefaults.Options)); return new {version.Revision,decision,simulationOnly=true}; }); app.MapPost("/api/tradingview",(TradingViewSignal input)=> { if(input.Exchange!="Bitget"||input.Symbol!=runtime.Symbol||input.Interval!=runtime.Interval||input.StrategyVersion!=runtime.StrategyVersion|| input.Signal is not ("BUY" or "SELL" or "SHORT" or "COVER")||input.Price<=0||!input.Confirmed|| input.BarTime<=0||input.BarTime%runtime.IntervalMilliseconds!=0||input.BarTime+runtime.IntervalMilliseconds>TradingStore.Now()+5000|| TradingStore.Now()-input.BarTime>7L*24*3600*1000) return Results.BadRequest(new {error="전략 버전·거래소·종목·봉 주기·확정 봉 시각·신호를 확인하세요. 7일 이전 신호는 수신하지 않습니다."}); var inserted=store.RecordSignal(new("TradingView",input.Exchange,input.Symbol,input.Interval,input.StrategyVersion,input.BarTime,input.Signal,input.Price,store.GetSettings().Revision,"webhook")); return Results.Ok(new {recorded=inserted,duplicate=!inserted}); }); app.Run(); static bool SecretMatch(string supplied,string expected)=>CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(supplied)),SHA256.HashData(Encoding.UTF8.GetBytes(expected))); public sealed record TradingViewSignal(string Exchange,string Symbol,string Interval,string StrategyVersion,long BarTime,string Signal,decimal Price,bool Confirmed); public sealed record ModeRequest(string Mode,string? Confirmation); public sealed record ConfirmationRequest(string? Confirmation); public sealed record CloseRequest(string Direction,string? Confirmation); public sealed record CancelRequest(string Id);