2026-04-25 17:32:49 +09:00
using Buyer ;
2026-10-20 17:57:28 +09:00
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 ) = >
2026-04-26 19:00:54 +09:00
{
2026-10-20 17:57:28 +09:00
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 ) = >
2026-04-25 17:32:49 +09:00
{
2026-10-20 17:57:28 +09:00
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 ) = >
2026-04-25 17:32:49 +09:00
{
2026-10-20 17:57:28 +09:00
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 ) = >
2026-04-25 17:32:49 +09:00
{
2026-10-20 17:57:28 +09:00
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 ) ;