83 lines
7.1 KiB
C#
83 lines
7.1 KiB
C#
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<ObservationWorker>();
|
|
builder.Services.AddSingleton<ITradingVenue>(_=>new BitgetVenue(new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(8)},Environment.GetEnvironmentVariable));
|
|
builder.Services.AddSingleton<ExecutionEngine>();builder.Services.AddHostedService<ExecutionWorker>();
|
|
builder.Services.AddSingleton(sp=>new AccountRegistry(
|
|
accountsPath,
|
|
builder.Configuration["Buyer:KeyRoot"]??Path.Combine(Path.GetDirectoryName(databasePath)!,".secrets","accounts"),
|
|
store,sp.GetRequiredService<ExecutionEngine>(),credentials=>new BitgetVenue(
|
|
new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(8)},credentials),sp.GetRequiredService<ITradingVenue>().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);
|